refactor(webui): extract repeated dashboard elements into shared components

Markup and helpers that were copied between pages now live in one
place:
- ChartCard, StatDisabledAlert, StatItem, DetailField, BudgetRatioBars,
  LabeledSelect, TargetPicker and LoadStateView components
- lib/scope.ts, lib/columns.ts and lib/chart-palette.ts
- messageOf, isAuthRejection, and the USD to micro-USD conversion
  helpers
- one monthly cost projection, one date formatter, and the
  useCopyFeedback composable

Behaviour is unchanged. Before and after screenshots of every page
match at 390 and 1440 px. Empty-state text now comes from the shared
EmptyState component, and the user run-out date uses the short date
format. admin_assets_gen.go is regenerated.

Claude-Session: https://claude.ai/code/session_01V5jYJNxpDFdJkyyExpADrv
This commit is contained in:
2026-09-24 06:04:46 +01:00
parent ce39b8eb37
commit 2608e01c50
67 changed files with 2008 additions and 1136 deletions
+82 -64
View File
File diff suppressed because one or more lines are too long
+12 -22
View File
@@ -11,13 +11,12 @@ import EmptyState from '@/components/EmptyState.vue'
import EntityLink from '@/components/EntityLink.vue'
import ErrorState from '@/components/ErrorState.vue'
import SkeletonTable from '@/components/SkeletonTable.vue'
import StatDisabledAlert from '@/components/StatDisabledAlert.vue'
import TryLongerRangeButton from '@/components/TryLongerRangeButton.vue'
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { formatCost } from '@/lib/format'
import { loadState } from '@/lib/load-state'
import { useNavStore } from '@/stores/nav'
import { drilldownLevel, makeScope } from '@/lib/scope'
import { SPEND_METRIC_LABEL } from '@/stores/spend'
import type { AdminTotalsResponse, HistoryMetric, HistoryWindow } from '@/types/api'
@@ -70,15 +69,7 @@ const props = defineProps<{
const emit = defineEmits<{ 'update:drill': [value: string] }>()
const nav = useNavStore()
type Level = { kind: 'group' } | { kind: 'user'; group: string } | { kind: 'usermodel'; user: string }
const level = computed<Level>(() => {
if (props.drill === '') return { kind: 'group' }
if (props.drill.startsWith('group:')) return { kind: 'user', group: props.drill.slice('group:'.length) }
return { kind: 'usermodel', user: props.drill.slice('user:'.length) }
})
const level = computed(() => drilldownLevel(props.drill))
/**
* parentGroupLabel is a BEST-EFFORT breadcrumb label for the group a
@@ -97,15 +88,15 @@ function goToGroups(): void {
emit('update:drill', '')
}
function goToParentGroup(): void {
if (parentGroupLabel.value) emit('update:drill', `group:${parentGroupLabel.value}`)
if (parentGroupLabel.value) emit('update:drill', makeScope('group', parentGroupLabel.value))
}
function onRowClick(id: string): void {
const l = level.value
if (l.kind === 'group') {
emit('update:drill', `group:${id}`)
emit('update:drill', makeScope('group', id))
} else if (l.kind === 'user' && props.userModelStatsEnabled) {
parentGroupLabel.value = l.group
emit('update:drill', `user:${id}`)
emit('update:drill', makeScope('user', id))
}
}
@@ -189,13 +180,12 @@ const noTrafficInRange = computed<boolean>(() => !showUserModelHint.value && pro
</CardHeader>
<CardContent class="flex flex-col gap-3">
<ClampedRangeNotice v-if="level.kind === 'usermodel'" :window="window" :span="span" />
<Alert v-if="showUserModelHint" variant="warn">
<AlertTitle>Per-user-model statistics are off</AlertTitle>
<AlertDescription class="flex flex-wrap items-center gap-2">
<span>Enable <code class="font-mono text-xs">admin.stats.userModel</code> in the middleware config to see per-model attribution for this user.</span>
<Button type="button" variant="outline" size="sm" @click="nav.goTo('config')">Open Config</Button>
</AlertDescription>
</Alert>
<StatDisabledAlert
v-if="showUserModelHint"
title="Per-user-model statistics are off"
config-key="admin.stats.userModel"
purpose="to see per-model attribution for this user."
/>
<SkeletonTable v-else-if="state === 'skeleton'" :rows="5" :cols="metrics.length + 1" />
<ErrorState v-else-if="state === 'error'" :message="error" :on-retry="onRetry" />
<EmptyState v-else-if="noTrafficInRange" title="No traffic in this range.">
+26
View File
@@ -0,0 +1,26 @@
<script setup lang="ts">
import UsageBar from '@/components/UsageBar.vue'
import { BUDGET_RATIO_LABEL, budgetValueText } from '@/lib/usage-bars'
import type { BudgetRatio } from '@/lib/usage-bars'
/**
* BudgetRatioBars (reuse-audit.md F7) is the ONE budget-ratio bar row —
* one labelled UsageBar per configured limit — UserDetail.vue's Headroom
* card and ConsumerDirectory.vue's group accordion both hand-rolled: a
* label (BUDGET_RATIO_LABEL) beside each lib/usage-bars.ts budgetRatios()
* entry, its UsageBar sized to that ratio, and its "used / limit" text
* (the shared budgetValueText).
*/
defineProps<{
ratios: BudgetRatio[]
}>()
</script>
<template>
<div class="flex flex-wrap items-center gap-x-4 gap-y-1.5">
<span v-for="ratio in ratios" :key="ratio.id" class="inline-flex items-center gap-1.5 text-xs text-muted-foreground">
{{ BUDGET_RATIO_LABEL[ratio.id] }}
<UsageBar :ratio="ratio.ratio" :label="BUDGET_RATIO_LABEL[ratio.id]" :value-text="budgetValueText(ratio)" />
</span>
</div>
</template>
+24 -33
View File
@@ -1,13 +1,13 @@
<script setup lang="ts">
import { computed } from 'vue'
import ErrorState from '@/components/ErrorState.vue'
import SkeletonChart from '@/components/SkeletonChart.vue'
import ChartCard from '@/components/ChartCard.vue'
import StatItem from '@/components/StatItem.vue'
import TimeSeriesChart from '@/components/TimeSeriesChart.vue'
import type { TimeSeriesDataset } from '@/components/TimeSeriesChart.vue'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { cumulative, runOutDate } from '@/lib/burndown'
import { formatBucketLabel, formatCost } from '@/lib/format'
import { NOT_ENOUGH_DATA } from '@/lib/forecast'
import { formatBucketLabel, formatCost, formatShortDate } from '@/lib/format'
import { loadState } from '@/lib/load-state'
/**
@@ -64,33 +64,25 @@ const willExceed = computed<boolean>(() => props.budgetMicros !== null && spentM
</script>
<template>
<Card>
<CardHeader>
<CardTitle>Burn-down</CardTitle>
<CardDescription>Cumulative spend this UTC calendar month against the configured budget.</CardDescription>
</CardHeader>
<CardContent class="flex flex-col gap-4">
<SkeletonChart v-if="state === 'skeleton'" />
<ErrorState v-else-if="state === 'error'" :message="error ?? ''" :on-retry="onRetry" />
<template v-else>
<ChartCard
title="Burn-down"
description="Cumulative spend this UTC calendar month against the configured budget."
:state="state"
:error="error ?? ''"
:on-retry="onRetry"
>
<div class="flex flex-col gap-4">
<div class="grid grid-cols-2 gap-4 text-sm sm:grid-cols-3">
<div>
<p class="text-muted-foreground">Spent so far</p>
<p class="text-lg font-semibold tabular-nums" :class="willExceed ? 'text-destructive' : undefined">
{{ formatCost(spentMicros) }}
</p>
</div>
<div v-if="budgetMicros !== null">
<p class="text-muted-foreground">Budget</p>
<p class="text-lg font-semibold tabular-nums">{{ formatCost(budgetMicros) }}</p>
</div>
<div v-if="budgetMicros !== null">
<p class="text-muted-foreground">Projected run-out</p>
<p v-if="runOut === null" class="text-sm text-muted-foreground">not enough data yet</p>
<p v-else class="text-lg font-semibold tabular-nums" :class="willExceed ? 'text-destructive' : undefined">
{{ runOut.toLocaleDateString(undefined, { month: 'short', day: 'numeric' }) }}
</p>
</div>
<StatItem label="Spent so far" :tone="willExceed ? 'destructive' : 'default'">{{ formatCost(spentMicros) }}</StatItem>
<StatItem v-if="budgetMicros !== null" label="Budget">{{ formatCost(budgetMicros) }}</StatItem>
<StatItem
v-if="budgetMicros !== null"
label="Projected run-out"
:tone="willExceed ? 'destructive' : 'default'"
:hint="runOut === null ? NOT_ENOUGH_DATA : undefined"
>
{{ runOut === null ? '' : formatShortDate(runOut) }}
</StatItem>
</div>
<p v-if="budgetMicros === null" class="text-sm text-muted-foreground">No budget configured for this scope.</p>
<TimeSeriesChart
@@ -100,7 +92,6 @@ const willExceed = computed<boolean>(() => props.budgetMicros !== null && spentM
:value-formatter="formatCost"
ariaLabel="Cumulative spend this month against the configured budget"
/>
</template>
</CardContent>
</Card>
</div>
</ChartCard>
</template>
+121
View File
@@ -0,0 +1,121 @@
<script setup lang="ts">
import EmptyState from '@/components/EmptyState.vue'
import ErrorState from '@/components/ErrorState.vue'
import SkeletonChart from '@/components/SkeletonChart.vue'
import TimeSeriesChart from '@/components/TimeSeriesChart.vue'
import type { TimeSeriesComparisonDataset, TimeSeriesDataset } from '@/components/TimeSeriesChart.vue'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { formatExactInt } from '@/lib/format'
import type { LoadState } from '@/lib/load-state'
/**
* ChartCard (reuse-audit.md F1) is the ONE `Card > CardHeader(Title,
* Description?) > CardContent > skeleton|error|(empty)|chart` shell every
* time-series chart card in this panel used to hand-roll — ReliabilityPage
* (5 real chart cards + 4 skeleton-only loading cards), UserDetail
* (requests/cost), SpendPage (breakdown), BurnDownChart. `state` drives the
* skeleton/error/ready branch (lib/load-state.ts's LoadState — the SAME
* precedence every other card in this panel already follows); `error` and
* `onRetry` forward straight to ErrorState. The default slot overrides the
* ready-state body for a chart that needs more than a bare TimeSeriesChart
* (BurnDownChart's own stat row above its chart) — omitted, this renders
* TimeSeriesChart directly from `labels`/`datasets`/etc. `#header-extra`
* adds header content beyond title/description (e.g. a per-card control)
* without needing the caller to rebuild CardHeader itself.
*
* `showEmpty` (default false, opt-in) is the ONLY thing that turns
* `state === 'empty'` into a distinct EmptyState render instead of falling
* through to the ready branch (the default slot, or the bare
* TimeSeriesChart). None of today's call sites had an "empty" concept
* before this component existed — they always rendered their chart (or,
* for BurnDownChart, its stat row above the chart) for any non-skeleton,
* non-error state, even with zero data points. Defaulting `showEmpty` to
* false keeps that exact behaviour; a future card that genuinely wants
* "No data for this range." text can opt in per-instance.
*
* `bare` (true) skips the Card/CardHeader/CardTitle chrome entirely,
* rendering only the state-branch content — SpendPage's breakdown chart
* has its own bespoke header (Tabs, a metric Select, a running total) and,
* today, no Card wrapper at all around the chart; forcing one on would be
* a visible layout change this refactor must not make (behaviour-
* preserving rule), so `bare` lets it reuse the skeleton/error/ready
* branch without the surrounding card border/padding.
*/
const props = withDefaults(
defineProps<{
title?: string
description?: string
state: LoadState
error?: string
onRetry?: () => void | Promise<void>
emptyTitle?: string
emptyDescription?: string
/** Opt-in: renders EmptyState instead of the ready branch when `state === 'empty'` — see this component's own doc comment. Defaults to false (behaviour-preserving: 'empty' falls through to ready everywhere until a caller explicitly asks for the distinct empty render). */
showEmpty?: boolean
bare?: boolean
/** Omit when this ChartCard is only ever used in a non-'ready' state (e.g. one of the 4 skeleton-only loading cards below) — never read outside the 'ready' branch. */
labels?: string[]
datasets?: TimeSeriesDataset[]
comparisonDatasets?: TimeSeriesComparisonDataset[]
stacked?: boolean
valueFormatter?: (value: number) => string
/** Required whenever `state` can reach 'ready' with the default TimeSeriesChart body (TimeSeriesChart's own ariaLabel doc comment: no safe generic default) — optional here only so a skeleton-only ChartCard is not forced to pass a meaningless placeholder. */
ariaLabel?: string
}>(),
{
description: undefined,
error: '',
onRetry: undefined,
emptyTitle: 'No data for this range.',
emptyDescription: undefined,
showEmpty: false,
bare: false,
labels: () => [],
datasets: () => [],
comparisonDatasets: () => [],
stacked: false,
valueFormatter: formatExactInt,
ariaLabel: '',
},
)
</script>
<template>
<template v-if="bare">
<SkeletonChart v-if="state === 'skeleton'" />
<ErrorState v-else-if="state === 'error'" :message="error" :on-retry="onRetry" />
<EmptyState v-else-if="state === 'empty' && showEmpty" :title="emptyTitle" :description="emptyDescription" />
<slot v-else>
<TimeSeriesChart
:labels="labels"
:datasets="datasets"
:comparison-datasets="comparisonDatasets"
:stacked="stacked"
:value-formatter="valueFormatter"
:ariaLabel="ariaLabel"
/>
</slot>
</template>
<Card v-else>
<CardHeader>
<CardTitle>{{ title }}</CardTitle>
<CardDescription v-if="description">{{ description }}</CardDescription>
<slot name="header-extra" />
</CardHeader>
<CardContent>
<SkeletonChart v-if="state === 'skeleton'" />
<ErrorState v-else-if="state === 'error'" :message="error" :on-retry="onRetry" />
<EmptyState v-else-if="state === 'empty' && showEmpty" :title="emptyTitle" :description="emptyDescription" />
<slot v-else>
<TimeSeriesChart
:labels="labels"
:datasets="datasets"
:comparison-datasets="comparisonDatasets"
:stacked="stacked"
:value-formatter="valueFormatter"
:ariaLabel="ariaLabel"
/>
</slot>
</CardContent>
</Card>
</template>
+65 -123
View File
@@ -3,30 +3,32 @@ import type { SortingState } from '@tanstack/vue-table'
import { getCoreRowModel, getSortedRowModel, useVueTable } from '@tanstack/vue-table'
import { computed, onMounted, onUnmounted, reactive, ref, watch } from 'vue'
import BudgetRatioBars from '@/components/BudgetRatioBars.vue'
import CompactNumber from '@/components/CompactNumber.vue'
import CostForecastCard from '@/components/CostForecastCard.vue'
import CsvExportButton from '@/components/CsvExportButton.vue'
import DetailField from '@/components/DetailField.vue'
import EmptyState from '@/components/EmptyState.vue'
import ErrorState from '@/components/ErrorState.vue'
import LoadStateView from '@/components/LoadStateView.vue'
import ModelChip from '@/components/ModelChip.vue'
import SearchInput from '@/components/SearchInput.vue'
import SkeletonList from '@/components/SkeletonList.vue'
import SkeletonTable from '@/components/SkeletonTable.vue'
import SortHeaderButton from '@/components/SortHeaderButton.vue'
import StatItem from '@/components/StatItem.vue'
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from '@/components/ui/accordion'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
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 { headroom, monthProgress, projectMonthEnd } from '@/lib/forecast'
import { formatCost, formatExactInt, formatLimits } from '@/lib/format'
import { monthCostProjection, NOT_ENOUGH_DATA, WILL_EXCEED_TITLE } from '@/lib/forecast'
import { formatCost, formatLimits } from '@/lib/format'
import { loadState } from '@/lib/load-state'
import { type ExpandState, clearExpandOverrides, computeExpandedItems, toggleItemExpand } from '@/lib/search-expand'
import { BUDGET_RATIO_LABEL, type BudgetRatio, budgetRatios } from '@/lib/usage-bars'
import { 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'
@@ -227,19 +229,30 @@ function isGlobPattern(entry: string): boolean {
return /[*?[]/.test(entry)
}
/** budgetValueText renders one BudgetRatio's "used / limit" detail text (UsageBar's aria-valuetext/title). */
function budgetValueText(ratio: BudgetRatio): string {
if (ratio.id.startsWith('cost')) return `${formatCost(ratio.used)} / ${formatCost(ratio.limit)}`
return `${formatExactInt(ratio.used)} / ${formatExactInt(ratio.limit)}`
}
/** groupCostProjection mirrors usage-columns.ts's own costMonthProjColumn math for one group row. */
/** groupCostProjection renders lib/forecast.ts's shared monthCostProjection (reuse-audit.md F7) for one group row as display text. */
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 }
const projection = monthCostProjection(entry, now)
return { text: projection.micros === null ? NOT_ENOUGH_DATA : formatCost(projection.micros), willExceed: projection.willExceed }
}
/**
* GROUP_DETAIL_FIELDS (reuse-audit.md F6) drives the group accordion's 4
* uniform "storeDown ? '?' : <CompactNumber>" detail fields with one
* v-for instead of four hand-rolled DetailField copies — the same label/
* accessor pairs lib/usage-columns.ts's own numericColumn calls already
* name for these exact ids (tokInDay, tokOutDay, tokInMonth, tokOutMonth).
* req/min and rejected/day are rendered explicitly, ahead of this v-for,
* to keep the HEAD field order (rejected/day also needs its own
* conditional destructive-tone class). Not the ColumnDef objects
* themselves: a TanStack ColumnDef's `cell` needs a full CellContext
* (row/column/table) to render, which this plain `dl` grid — driven by
* `row.original` alone, no table instance — has no reason to construct.
*/
const GROUP_DETAIL_FIELDS: { label: string; read: (entry: AdminUsageEntryView) => number }[] = [
{ label: 'tokIn/day', read: (e) => e.tokensInPerDay },
{ label: 'tokOut/day', read: (e) => e.tokensOutPerDay },
{ label: 'tokIn/month', read: (e) => e.tokensInPerMonth },
{ label: 'tokOut/month', read: (e) => e.tokensOutPerMonth },
]
// --- CSV export ---
const usersColumns = computed(() => usageColumns('Name', 'Group', groupNameOf, now.value, sourceOf))
@@ -289,28 +302,13 @@ function groupsCsv(): string {
<CardDescription>Usage summed across every user and group combined.</CardDescription>
</CardHeader>
<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>
<p class="text-muted-foreground">Rejected/day</p>
<p class="text-lg font-semibold tabular-nums" :class="usage.total.rejectionsPerDay > 0 ? 'text-destructive' : undefined">
<StatItem label="Requests/day"><CompactNumber :value="usage.total.requestsPerDay" /></StatItem>
<StatItem label="Rejected/day" :tone="usage.total.rejectionsPerDay > 0 ? 'destructive' : 'default'">
<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>
</div>
<div>
<p class="text-muted-foreground">Tokens out/day</p>
<p class="text-lg font-semibold tabular-nums"><CompactNumber :value="usage.total.tokensOutPerDay" /></p>
</div>
<div>
<p class="text-muted-foreground">Cost/day</p>
<p class="text-lg font-semibold tabular-nums">{{ formatCost(usage.total.costPerDayMicroUsd) }}</p>
</div>
</StatItem>
<StatItem label="Tokens in/day"><CompactNumber :value="usage.total.tokensInPerDay" /></StatItem>
<StatItem label="Tokens out/day"><CompactNumber :value="usage.total.tokensOutPerDay" /></StatItem>
<StatItem label="Cost/day">{{ formatCost(usage.total.costPerDayMicroUsd) }}</StatItem>
</CardContent>
</Card>
@@ -354,9 +352,7 @@ function groupsCsv(): string {
</div>
<AccordionContent>
<dl class="mb-3 grid grid-cols-2 gap-x-6 gap-y-1.5 text-sm sm:grid-cols-4">
<div>
<dt class="text-xs text-muted-foreground" :title="GLOB_HINT">Providers</dt>
<dd>
<DetailField label="Providers" :label-title="GLOB_HINT">
<div v-if="row.original.providers?.length" class="flex flex-wrap gap-1.5">
<Badge
v-for="providerName in row.original.providers"
@@ -370,11 +366,8 @@ function groupsCsv(): string {
</Badge>
</div>
<span v-else class="text-muted-foreground">All providers</span>
</dd>
</div>
<div>
<dt class="text-xs text-muted-foreground" :title="GLOB_HINT">Models</dt>
<dd>
</DetailField>
<DetailField label="Models" :label-title="GLOB_HINT">
<div v-if="row.original.models?.length" class="flex flex-wrap gap-1.5">
<template v-for="modelId in row.original.models" :key="modelId">
<ModelChip v-if="!isGlobPattern(modelId)" :id="modelId" />
@@ -382,11 +375,8 @@ function groupsCsv(): string {
</template>
</div>
<span v-else class="text-muted-foreground">All models</span>
</dd>
</div>
<div>
<dt class="text-xs text-muted-foreground" :title="GLOB_HINT">MCP servers</dt>
<dd>
</DetailField>
<DetailField label="MCP servers" :label-title="GLOB_HINT">
<div v-if="row.original.mcpServers?.length" class="flex flex-wrap gap-1.5">
<Badge
v-for="serverName in row.original.mcpServers"
@@ -400,11 +390,8 @@ function groupsCsv(): string {
</Badge>
</div>
<span v-else class="text-muted-foreground">All MCP servers</span>
</dd>
</div>
<div>
<dt class="text-xs text-muted-foreground" :title="GLOB_HINT">Agents</dt>
<dd>
</DetailField>
<DetailField label="Agents" :label-title="GLOB_HINT">
<div v-if="row.original.agents?.length" class="flex flex-wrap gap-1.5">
<Badge
v-for="agentName in row.original.agents"
@@ -418,81 +405,37 @@ function groupsCsv(): string {
</Badge>
</div>
<span v-else class="text-muted-foreground">All agents</span>
</dd>
</div>
</DetailField>
</dl>
<dl class="mb-3 grid grid-cols-2 gap-x-6 gap-y-1.5 text-sm sm:grid-cols-4">
<div>
<dt class="text-xs text-muted-foreground">Limits</dt>
<dd>{{ formatLimits(row.original.limits) }}</dd>
</div>
<div>
<dt class="text-xs text-muted-foreground">req/min</dt>
<dd class="tabular-nums">
<DetailField label="Limits">{{ formatLimits(row.original.limits) }}</DetailField>
<DetailField label="req/min" numeric>
<template v-if="row.original.storeDown">?</template>
<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">
</DetailField>
<DetailField
label="rejected/day"
numeric
: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">
</DetailField>
<DetailField v-for="field in GROUP_DETAIL_FIELDS" :key="field.label" :label="field.label" numeric>
<template v-if="row.original.storeDown">?</template>
<CompactNumber v-else :value="row.original.tokensInPerDay" />
</dd>
</div>
<div>
<dt class="text-xs text-muted-foreground">tokOut/day</dt>
<dd class="tabular-nums">
<template v-if="row.original.storeDown">?</template>
<CompactNumber v-else :value="row.original.tokensOutPerDay" />
</dd>
</div>
<div>
<dt class="text-xs text-muted-foreground">tokIn/month</dt>
<dd class="tabular-nums">
<template v-if="row.original.storeDown">?</template>
<CompactNumber v-else :value="row.original.tokensInPerMonth" />
</dd>
</div>
<div>
<dt class="text-xs text-muted-foreground">tokOut/month</dt>
<dd class="tabular-nums">
<template v-if="row.original.storeDown">?</template>
<CompactNumber v-else :value="row.original.tokensOutPerMonth" />
</dd>
</div>
<div>
<dt class="text-xs text-muted-foreground">cost/month</dt>
<dd class="tabular-nums">{{ row.original.storeDown ? '?' : formatCost(row.original.costPerMonthMicroUsd) }}</dd>
</div>
<div>
<dt class="text-xs text-muted-foreground">cost/month (proj.)</dt>
<dd
class="tabular-nums"
<CompactNumber v-else :value="field.read(row.original)" />
</DetailField>
<DetailField label="cost/month" numeric>{{ row.original.storeDown ? '?' : formatCost(row.original.costPerMonthMicroUsd) }}</DetailField>
<DetailField
label="cost/month (proj.)"
numeric
: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"
:title="!row.original.storeDown && groupCostProjection(row.original, now).willExceed ? WILL_EXCEED_TITLE : undefined"
>
{{ row.original.storeDown ? '?' : groupCostProjection(row.original, now).text }}
</dd>
</div>
</DetailField>
</dl>
<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>
<BudgetRatioBars v-if="budgetRatios(row.original).length" class="mb-3" :ratios="budgetRatios(row.original)" />
<p class="mb-1.5 text-xs font-medium text-muted-foreground">Members</p>
<UsageTable
id-label="Name"
@@ -508,7 +451,7 @@ function groupsCsv(): string {
<EmptyState v-else-if="noGroupsConfigured" title="No groups configured." description="Grant a group access from the Config page's change helper.">
<Button type="button" variant="outline" size="sm" @click="nav.goTo('config', { helper: 'grant' })">Open change helper</Button>
</EmptyState>
<p v-else class="py-6 text-center text-sm text-muted-foreground">{{ groupsEmptyMessage }}</p>
<EmptyState v-else :title="groupsEmptyMessage" />
</template>
</CardContent>
</Card>
@@ -524,10 +467,8 @@ function groupsCsv(): string {
</div>
</CardHeader>
<CardContent>
<SkeletonTable v-if="dashboardLoadState === 'skeleton'" :rows="5" :cols="5" />
<ErrorState v-else-if="dashboardLoadState === 'error'" :message="dashboard.error" :on-retry="dashboard.refresh" />
<LoadStateView :state="dashboardLoadState" :error="dashboard.error" :on-retry="dashboard.refresh" skeleton="table" :rows="5" :cols="5">
<UsageTable
v-else
id-label="Name"
secondary-column-label="Group"
:entries="filteredUsers"
@@ -537,6 +478,7 @@ function groupsCsv(): string {
:source-value="sourceOf"
v-model:sorting="usersSorting"
/>
</LoadStateView>
</CardContent>
</Card>
</div>
+12 -12
View File
@@ -1,8 +1,8 @@
<script setup lang="ts">
import { computed } from 'vue'
import LabeledSelect from '@/components/LabeledSelect.vue'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { costAvoidedMicros, freeRows, pickReferenceModel } from '@/lib/cost-avoided'
import { formatCost } from '@/lib/format'
import type { AdminCatalogModel, AdminUsageModelEntry } from '@/types/api'
@@ -81,6 +81,8 @@ const priceableOptions = computed<{ id: string; requests: number }[]>(() => {
return out.sort((a, b) => b.requests - a.requests)
})
const referenceOptions = computed(() => priceableOptions.value.map((opt) => ({ value: opt.id, label: opt.id })))
const defaultReference = computed<string | null>(() => pickReferenceModel(props.models, props.catalog))
const effectiveReference = computed<string | null>(() => props.refModel || defaultReference.value)
const referenceCatalog = computed<AdminCatalogModel | undefined>(() =>
@@ -111,17 +113,15 @@ function onReferenceUpdate(value: unknown): void {
<CardTitle>Cost avoided</CardTitle>
<CardDescription>What free-tier traffic in range would have cost at a real model's own rates.</CardDescription>
</div>
<div v-if="priceableOptions.length > 0" class="flex flex-col gap-1">
<label id="cost-avoided-ref-label" class="text-xs font-medium text-muted-foreground">Reference model</label>
<Select :model-value="effectiveReference ?? undefined" @update:model-value="onReferenceUpdate">
<SelectTrigger aria-labelledby="cost-avoided-ref-label" class="w-56">
<SelectValue placeholder="Pick a reference model" />
</SelectTrigger>
<SelectContent>
<SelectItem v-for="opt in priceableOptions" :key="opt.id" :value="opt.id">{{ opt.id }}</SelectItem>
</SelectContent>
</Select>
</div>
<LabeledSelect
v-if="priceableOptions.length > 0"
label="Reference model"
:model-value="effectiveReference ?? ''"
:options="referenceOptions"
placeholder="Pick a reference model"
trigger-class="w-56"
@update:model-value="onReferenceUpdate"
/>
</CardHeader>
<CardContent class="grid grid-cols-2 gap-4 text-sm">
<div>
+11 -20
View File
@@ -1,8 +1,9 @@
<script setup lang="ts">
import { computed } from 'vue'
import StatItem from '@/components/StatItem.vue'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { headroom, monthProgress, projectMonthEnd } from '@/lib/forecast'
import { headroom, monthProgress, NOT_ENOUGH_DATA, projectMonthEnd, WILL_EXCEED_TITLE } from '@/lib/forecast'
import { formatCost } from '@/lib/format'
import type { LimitsConfig } from '@/types/api'
@@ -53,26 +54,16 @@ const forecast = computed(() => headroom(projectedMicros.value, props.limits?.co
<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"
<StatItem label="Month to date">{{ formatCost(mtdMicros) }}</StatItem>
<StatItem
label="Projected month-end"
:tone="forecast.willExceed ? 'destructive' : 'default'"
:title="forecast.willExceed ? WILL_EXCEED_TITLE : undefined"
:hint="projectedMicros === null ? NOT_ENOUGH_DATA : 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>
{{ projectedMicros === null ? '' : formatCost(projectedMicros) }}
</StatItem>
<StatItem v-if="forecast.limitMicros !== null" label="Limit">{{ formatCost(forecast.limitMicros) }}</StatItem>
</CardContent>
</Card>
</template>
+35
View File
@@ -0,0 +1,35 @@
<script setup lang="ts">
/**
* DetailField (reuse-audit.md F6) is the ONE `dt`/`dd` detail-field pair
* every accordion/expanded-row detail grid in this panel used to hand-
* roll: a small muted `dt` label (`labelTitle` for a hint on the LABEL
* itself, e.g. the glob-pattern hint on a group's Providers/Models/MCP
* servers/Agents fields) and a `dd` for the value (default slot) —
* `numeric` adds `tabular-nums` to the `dd` for a figure that should align
* in a monospaced-digit grid. `inheritAttrs: false` + `v-bind="$attrs"` on
* the `dd` (not the root `div`) forwards any other attribute a caller
* passes straight onto the VALUE element — a conditional `class` (e.g. a
* destructive rejected-count) or `title` (e.g. a "projected to exceed"
* tooltip) lands on the same element it did before this extraction, never
* on the label.
*/
defineOptions({ inheritAttrs: false })
withDefaults(
defineProps<{
label: string
labelTitle?: string
numeric?: boolean
}>(),
{ labelTitle: undefined, numeric: false },
)
</script>
<template>
<div>
<dt class="text-xs text-muted-foreground" :title="labelTitle">{{ label }}</dt>
<dd v-bind="$attrs" :class="numeric ? 'tabular-nums' : undefined">
<slot />
</dd>
</div>
</template>
+3 -12
View File
@@ -2,9 +2,9 @@
import { faArrowRightArrowLeft } from '@fortawesome/free-solid-svg-icons'
import { computed, ref, watch } from 'vue'
import LabeledSelect from '@/components/LabeledSelect.vue'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { SCOPE_PATTERN } from '@/lib/hash-state'
import { RANGE_KEYS } from '@/lib/range'
import type { RangeKey } from '@/lib/range'
@@ -21,6 +21,7 @@ const RANGE_LABEL: Record<RangeKey, string> = {
'6mo': 'Last 6 months',
'12mo': 'Last 12 months',
}
const RANGE_OPTIONS = RANGE_KEYS.map((key) => ({ value: key, label: RANGE_LABEL[key] }))
/**
* GlobalFilterBar (redesign-plan.md section 3.1) is the shell's one
@@ -94,17 +95,7 @@ function commitScope(): void {
<template>
<div class="flex flex-wrap items-end gap-3">
<div class="flex flex-col gap-1">
<label id="global-filter-range-label" class="text-xs font-medium text-muted-foreground">Range</label>
<Select :model-value="filters.range" @update:model-value="onRangeUpdate">
<SelectTrigger aria-labelledby="global-filter-range-label" class="w-36">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem v-for="key in RANGE_KEYS" :key="key" :value="key">{{ RANGE_LABEL[key] }}</SelectItem>
</SelectContent>
</Select>
</div>
<LabeledSelect label="Range" :model-value="filters.range" :options="RANGE_OPTIONS" trigger-class="w-36" @update:model-value="onRangeUpdate" />
<Button
type="button"
+65
View File
@@ -0,0 +1,65 @@
<script setup lang="ts">
import { computed, useId } from 'vue'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
export interface LabeledSelectOption {
value: string
label: string
}
/**
* LabeledSelect (reuse-audit.md F11) is the ONE "label above a Select,
* wired via aria-labelledby" block this panel used to hand-roll at every
* filter/target picker (GlobalFilterBar's Range, CostAvoidedCard's
* reference model, SpendPage's Metric, ReliabilityPage's Provider,
* UserForm's Primary group). `allLabel`, when set, injects an "All X"
* option and maps it to/from `modelValue === ''` internally — Radix/
* reka-ui Select items cannot hold an empty-string value themselves
* (EventsView.vue's/ReliabilityPage.vue's own identical ALL_KINDS/
* ALL_PROVIDERS_OPTION doc comments), so every "All" filter used to
* re-implement this same sentinel swap at its own template boundary.
* Omit `allLabel` for a plain required-selection picker with no "All"
* concept (UserForm's Primary group, CostAvoidedCard's reference model).
*/
const props = withDefaults(
defineProps<{
label: string
modelValue: string
options: LabeledSelectOption[]
allLabel?: string
placeholder?: string
triggerClass?: string
}>(),
{ allLabel: undefined, placeholder: undefined, triggerClass: undefined },
)
const emit = defineEmits<{ 'update:modelValue': [value: string] }>()
/** ALL_SENTINEL is LabeledSelect's own internal Select-only stand-in for modelValue === '' — never exposed to a caller, which only ever sees '' itself via v-model. */
const ALL_SENTINEL = '__all__'
const labelId = `labeled-select-${useId()}`
const selectValue = computed(() => (props.modelValue === '' && props.allLabel !== undefined ? ALL_SENTINEL : props.modelValue))
function onUpdate(value: unknown): void {
if (typeof value !== 'string') return
emit('update:modelValue', value === ALL_SENTINEL ? '' : value)
}
</script>
<template>
<div class="flex flex-col gap-1">
<label :id="labelId" class="text-xs font-medium text-muted-foreground">{{ label }}</label>
<Select :model-value="selectValue" @update:model-value="onUpdate">
<SelectTrigger :aria-labelledby="labelId" :class="triggerClass">
<SelectValue :placeholder="placeholder" />
</SelectTrigger>
<SelectContent>
<SelectItem v-if="allLabel !== undefined" :value="ALL_SENTINEL">{{ allLabel }}</SelectItem>
<SelectItem v-for="opt in options" :key="opt.value" :value="opt.value">{{ opt.label }}</SelectItem>
</SelectContent>
</Select>
</div>
</template>
+68
View File
@@ -0,0 +1,68 @@
<script setup lang="ts">
import type { IconDefinition } from '@fortawesome/free-solid-svg-icons'
import EmptyState from '@/components/EmptyState.vue'
import ErrorState from '@/components/ErrorState.vue'
import SkeletonChart from '@/components/SkeletonChart.vue'
import SkeletonList from '@/components/SkeletonList.vue'
import SkeletonTable from '@/components/SkeletonTable.vue'
import type { LoadState } from '@/lib/load-state'
/**
* LoadStateView (reuse-audit.md F2) is the ONE skeleton/error/(empty)/ready
* state branch for a list/table card with no Card wrapper of its own
* opinions (ChartCard.vue is this same idea for a chart card) — TargetCallers,
* HomePage's Latest-events card, and PricingHealthTable each used to
* hand-roll this identical `v-if="state==='skeleton'" / v-else-if=
* "state==='error'" / v-else-if="<own empty boolean>" / v-else` chain.
* `state` decides skeleton/error/ready; the 'empty' branch is opt-in only —
* it renders when the caller passes `empty: true` explicitly, never merely
* because `state === 'empty'`. Every pre-existing call site already derived
* its own "nothing to show" from its own data length (e.g. a settled fetch
* that came back with zero rows), exactly like their old hand-rolled
* chains did — `state === 'empty'` alone (no data ever loaded, not
* loading, no error) must fall through to the ready branch (`v-else`), so
* a caller that reaches this component only for its own
* table/list-with-empty-message body (e.g. UsageTable, DataTable) keeps
* rendering that body — and that body's own empty-message — unchanged,
* instead of losing it to a generic "Nothing to show." here. `#empty`
* overrides the default EmptyState entirely (TryLongerRangeButton, a
* custom icon) when a plain title/description is not enough.
*/
const props = withDefaults(
defineProps<{
state: LoadState
error?: string
onRetry?: () => void | Promise<void>
skeleton: 'table' | 'list' | 'chart'
rows?: number
cols?: number
/** The ONLY switch for the empty branch — see this component's own doc comment. `state === 'empty'` alone never triggers it. */
empty?: boolean
emptyIcon?: IconDefinition
emptyTitle?: string
emptyDescription?: string
}>(),
{
error: '',
onRetry: undefined,
rows: 5,
cols: 4,
empty: false,
emptyIcon: undefined,
emptyTitle: 'Nothing to show.',
emptyDescription: undefined,
},
)
</script>
<template>
<SkeletonTable v-if="state === 'skeleton' && skeleton === 'table'" :rows="rows" :cols="cols" />
<SkeletonList v-else-if="state === 'skeleton' && skeleton === 'list'" :rows="rows" />
<SkeletonChart v-else-if="state === 'skeleton'" />
<ErrorState v-else-if="state === 'error'" :message="error" :on-retry="onRetry" />
<slot v-else-if="empty" name="empty">
<EmptyState :icon="emptyIcon" :title="emptyTitle" :description="emptyDescription" />
</slot>
<slot v-else />
</template>
+7 -12
View File
@@ -6,14 +6,12 @@ import EmptyState from '@/components/EmptyState.vue'
import ErrorState from '@/components/ErrorState.vue'
import SearchInput from '@/components/SearchInput.vue'
import SkeletonTable from '@/components/SkeletonTable.vue'
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
import { Button } from '@/components/ui/button'
import StatDisabledAlert from '@/components/StatDisabledAlert.vue'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { filterModelRows } from '@/lib/model-filter'
import { loadState } from '@/lib/load-state'
import { modelCatalogColumns } from '@/lib/model-table-columns'
import type { ModelCatalogRow } from '@/lib/model-table-columns'
import { useNavStore } from '@/stores/nav'
/**
* ModelCatalogTable (redesign-plan.md section 3.4, Models & providers
@@ -41,8 +39,6 @@ const props = defineProps<{
const emit = defineEmits<{ 'update:query': [value: string] }>()
const nav = useNavStore()
const filtered = computed<ModelCatalogRow[]>(() => filterModelRows(props.rows, props.query))
const columns = modelCatalogColumns()
@@ -82,13 +78,12 @@ const emptyMessage = computed<string>(() => {
/>
</CardHeader>
<CardContent class="flex flex-col gap-3">
<Alert v-if="!latencyEnabled" variant="warn">
<AlertTitle>Latency statistics are off</AlertTitle>
<AlertDescription class="flex flex-wrap items-center gap-2">
<span>Enable <code>admin.stats.latency</code> in the middleware config to see fleet p50/p95.</span>
<Button type="button" variant="outline" size="sm" @click="nav.goTo('config')">Open Config</Button>
</AlertDescription>
</Alert>
<StatDisabledAlert
v-if="!latencyEnabled"
title="Latency statistics are off"
config-key="admin.stats.latency"
purpose="to see fleet p50/p95."
/>
<SkeletonTable v-if="catalogLoadState === 'skeleton'" :rows="5" :cols="columns.length" />
<ErrorState v-else-if="catalogLoadState === 'error'" :message="error ?? ''" :on-retry="onRetry" />
<EmptyState v-else-if="catalogLoadState === 'empty'" title="No models configured." />
+4 -16
View File
@@ -1,8 +1,9 @@
<script setup lang="ts">
import { faCheck, faCopy, faTriangleExclamation } from '@fortawesome/free-solid-svg-icons'
import { computed, onUnmounted, ref, useTemplateRef } from 'vue'
import { computed, useTemplateRef } from 'vue'
import { Badge } from '@/components/ui/badge'
import { useCopyFeedback } from '@/composables/useCopyFeedback'
import { copyText } from '@/lib/clipboard'
import { formatContextWindow, formatModelCostHover } from '@/lib/format'
import { useToastsStore } from '@/stores/toasts'
@@ -50,17 +51,9 @@ const props = defineProps<{
}>()
const labelRef = useTemplateRef<HTMLElement>('label')
const state = ref<'idle' | 'copied' | 'selected' | 'failed'>('idle')
let resetTimer: ReturnType<typeof setTimeout> | undefined
const { state, set: setCopyFeedback } = useCopyFeedback()
const toasts = useToastsStore()
function scheduleReset(): void {
clearTimeout(resetTimer)
resetTimer = setTimeout(() => {
state.value = 'idle'
}, 1500)
}
async function onClick(): Promise<void> {
const result = await copyText(props.id, labelRef.value ?? undefined)
// 'failed' (neither the Clipboard API nor the selection fallback
@@ -71,9 +64,8 @@ async function onClick(): Promise<void> {
// feedback right at the click target (the icon swap plus this same
// title/aria-label text), so a success toast on top would double-notify
// for something the reader is already looking straight at.
state.value = result
setCopyFeedback(result)
if (result === 'failed') toasts.push({ kind: 'error', message: `Could not copy ${props.id}.` })
scheduleReset()
}
const icon = {
@@ -119,10 +111,6 @@ const title = computed(() => {
const base = baseTitle.value[state.value]
return metaDetail.value ? `${base} · ${metaDetail.value}` : base
})
onUnmounted(() => {
clearTimeout(resetTimer)
})
</script>
<template>
+15 -45
View File
@@ -2,18 +2,17 @@
import type { ColumnDef } from '@tanstack/vue-table'
import { computed, h } from 'vue'
import CompactNumber from '@/components/CompactNumber.vue'
import CsvExportButton from '@/components/CsvExportButton.vue'
import DataTable from '@/components/DataTable.vue'
import EmptyState from '@/components/EmptyState.vue'
import EntityLink from '@/components/EntityLink.vue'
import ErrorState from '@/components/ErrorState.vue'
import SkeletonTable from '@/components/SkeletonTable.vue'
import LoadStateView from '@/components/LoadStateView.vue'
import TryLongerRangeButton from '@/components/TryLongerRangeButton.vue'
import { Badge } from '@/components/ui/badge'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { EMPTY_CELL, optionalColumn, usageMetricColumns } from '@/lib/columns'
import { toCsv } from '@/lib/csv'
import { formatContextWindow, formatCost, formatModelCostHover } from '@/lib/format'
import { formatContextWindow, formatModelCostHover } from '@/lib/format'
import { loadState } from '@/lib/load-state'
import { joinPricingHealth, pricingHealthOrder } from '@/lib/pricing-health'
import type { PricingHealthRow } from '@/lib/pricing-health'
@@ -73,10 +72,14 @@ function sourceCell(row: PricingHealthRow) {
}
function priceCell(row: PricingHealthRow) {
if (row.inputPerMTokUsd === undefined || row.outputPerMTokUsd === undefined) return h('span', { class: 'text-muted-foreground' }, '—')
if (row.inputPerMTokUsd === undefined || row.outputPerMTokUsd === undefined) return h('span', { class: 'text-muted-foreground' }, EMPTY_CELL)
return h('span', {}, formatModelCostHover(row.inputPerMTokUsd, row.outputPerMTokUsd))
}
// reuse-audit.md F9: context/requests/tokensIn/tokensOut/cost are the
// identical shape lib/model-table-columns.ts's own modelCatalogColumns
// builds for ModelCatalogRow — shared via lib/columns.ts's generic
// factories rather than a second hand-rolled copy for this row type.
const columns: ColumnDef<PricingHealthRow, unknown>[] = [
{
id: 'id',
@@ -96,41 +99,8 @@ const columns: ColumnDef<PricingHealthRow, unknown>[] = [
enableSorting: false,
cell: ({ row }) => priceCell(row.original),
},
{
id: 'context',
header: 'Context',
meta: { align: 'right' },
accessorFn: (r) => r.contextTokens ?? 0,
cell: ({ row }) => h('span', { class: 'tabular-nums' }, row.original.contextTokens !== undefined ? formatContextWindow(row.original.contextTokens) : '—'),
},
{
id: 'requests',
header: 'Requests',
meta: { align: 'right' },
accessorFn: (r) => r.requests,
cell: ({ row }) => h(CompactNumber, { value: row.original.requests }),
},
{
id: 'tokensIn',
header: 'Tokens in',
meta: { align: 'right' },
accessorFn: (r) => r.tokensIn,
cell: ({ row }) => h(CompactNumber, { value: row.original.tokensIn }),
},
{
id: 'tokensOut',
header: 'Tokens out',
meta: { align: 'right' },
accessorFn: (r) => r.tokensOut,
cell: ({ row }) => h(CompactNumber, { value: row.original.tokensOut }),
},
{
id: 'cost',
header: 'Cost',
meta: { align: 'right' },
accessorFn: (r) => r.costMicroUsd,
cell: ({ row }) => formatCost(row.original.costMicroUsd),
},
optionalColumn<PricingHealthRow>('context', 'Context', (r) => r.contextTokens, formatContextWindow),
...usageMetricColumns<PricingHealthRow>(),
]
/**
@@ -171,17 +141,17 @@ function pricingHealthCsv(): string {
<CardDescription>Every model served in range, joined with its billing price source.</CardDescription>
</CardHeader>
<CardContent class="flex flex-col gap-3">
<SkeletonTable v-if="state === 'skeleton'" :rows="5" :cols="columns.length" />
<ErrorState v-else-if="state === 'error'" :message="error" :on-retry="onRetry" />
<EmptyState v-else-if="rows.length === 0" title="No traffic in this range.">
<LoadStateView :state="state" :error="error" :on-retry="onRetry" skeleton="table" :rows="5" :cols="columns.length" :empty="rows.length === 0">
<template #empty>
<EmptyState title="No traffic in this range.">
<TryLongerRangeButton />
</EmptyState>
<template v-else>
</template>
<DataTable :columns="columns" :data="rows" empty-message="none" />
<div class="flex justify-end">
<CsvExportButton filename="pricing-health.csv" :build="pricingHealthCsv" />
</div>
</template>
</LoadStateView>
</CardContent>
</Card>
</template>
+20 -31
View File
@@ -8,16 +8,17 @@ import {
import { computed, h, reactive, watch } from 'vue'
import DataTable from '@/components/DataTable.vue'
import DetailField from '@/components/DetailField.vue'
import EmptyState from '@/components/EmptyState.vue'
import EntityLink from '@/components/EntityLink.vue'
import ErrorState from '@/components/ErrorState.vue'
import ModelChip from '@/components/ModelChip.vue'
import ProviderRateBadge from '@/components/ProviderRateBadge.vue'
import SearchInput from '@/components/SearchInput.vue'
import SkeletonList from '@/components/SkeletonList.vue'
import StatDisabledAlert from '@/components/StatDisabledAlert.vue'
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from '@/components/ui/accordion'
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import {
Card,
CardContent,
@@ -356,22 +357,20 @@ const aliasEmptyMessage = computed(() =>
<SearchInput v-model="modelQuery" placeholder="Search models or aliases..." class="mt-2 max-w-sm" />
</CardHeader>
<CardContent class="flex flex-col gap-3">
<Alert v-if="!models.latencyEnabled" variant="warn">
<AlertTitle>Latency statistics are off</AlertTitle>
<AlertDescription class="flex flex-wrap items-center gap-2">
<span>Enable <code>admin.stats.latency</code> in the middleware config to see fleet p50/p95.</span>
<Button type="button" variant="outline" size="sm" @click="nav.goTo('config')">Open Config</Button>
</AlertDescription>
</Alert>
<StatDisabledAlert
v-if="!models.latencyEnabled"
title="Latency statistics are off"
config-key="admin.stats.latency"
purpose="to see fleet p50/p95."
/>
<SkeletonList v-if="providersLoadState === 'skeleton'" :rows="3" />
<ErrorState v-else-if="providersLoadState === 'error'" :message="dashboard.error" :on-retry="dashboard.refresh" />
<p v-else-if="!overview?.providers.length" class="py-6 text-center text-sm text-muted-foreground">none</p>
<p
<EmptyState v-else-if="!overview?.providers.length" title="No providers configured." />
<EmptyState
v-else-if="hasQuery && filteredProviders.length === 0"
class="py-6 text-center text-sm text-muted-foreground"
>
no providers match &quot;{{ modelQuery }}&quot;
</p>
title="No matches."
:description='`No providers match "${modelQuery}".`'
/>
<template v-else>
<p class="text-xs text-muted-foreground">{{ perReplicaCaveat() }} Fleet p50/p95/timeouts/failovers below are fleet-wide, not per-replica.</p>
<Accordion v-model="expandedProviderValues" type="multiple" class="rounded-md border px-3">
@@ -488,22 +487,12 @@ const aliasEmptyMessage = computed(() =>
</div>
<AccordionContent>
<dl class="mb-3 grid grid-cols-2 gap-x-6 gap-y-1.5 text-sm sm:grid-cols-3">
<div>
<dt class="text-xs text-muted-foreground">Base URL</dt>
<dd class="break-all">{{ p.baseUrl }}</dd>
</div>
<div>
<dt class="text-xs text-muted-foreground">Last refresh</dt>
<dd>{{ refreshDetailLabel(p.discoveryEnabled, p.lastRefresh) }}</dd>
</div>
<div v-if="p.healthState !== 'closed'">
<dt class="text-xs text-muted-foreground">Discovery health</dt>
<dd :class="healthDetailClass(p.healthState)">{{ healthBadgeLabel(p) }}</dd>
</div>
<div v-if="p.lastErr">
<dt class="text-xs text-muted-foreground">Last error</dt>
<dd class="text-destructive">{{ p.lastErr }}</dd>
</div>
<DetailField label="Base URL" class="break-all">{{ p.baseUrl }}</DetailField>
<DetailField label="Last refresh">{{ refreshDetailLabel(p.discoveryEnabled, p.lastRefresh) }}</DetailField>
<DetailField v-if="p.healthState !== 'closed'" label="Discovery health" :class="healthDetailClass(p.healthState)">
{{ healthBadgeLabel(p) }}
</DetailField>
<DetailField v-if="p.lastErr" label="Last error" class="text-destructive">{{ p.lastErr }}</DetailField>
</dl>
<div v-if="visibleModels(p).length" class="flex flex-wrap gap-1.5">
<span v-for="m in visibleModels(p)" :key="m" class="inline-flex min-w-0 max-w-full items-center gap-1">
+6 -11
View File
@@ -1,8 +1,9 @@
<script setup lang="ts">
import { faCheck, faCopy, faTriangleExclamation } from '@fortawesome/free-solid-svg-icons'
import { onUnmounted, ref, useTemplateRef } from 'vue'
import { useTemplateRef } from 'vue'
import { Button } from '@/components/ui/button'
import { useCopyFeedback } from '@/composables/useCopyFeedback'
import { copyText } from '@/lib/clipboard'
import { useToastsStore } from '@/stores/toasts'
@@ -21,21 +22,17 @@ const props = defineProps<{
}>()
const preRef = useTemplateRef<HTMLElement>('pre')
const state = ref<'idle' | 'copied' | 'selected' | 'failed'>('idle')
let resetTimer: ReturnType<typeof setTimeout> | undefined
const { state, set: setCopyFeedback } = useCopyFeedback()
const toasts = useToastsStore()
async function onCopy(): Promise<void> {
state.value = await copyText(props.text, preRef.value ?? undefined)
const result = await copyText(props.text, preRef.value ?? undefined)
setCopyFeedback(result)
// Only a FAILURE also gets a toast (states-plan.md item 3) — see
// ModelChip.vue's own identical doc comment: 'copied'/'selected'
// already have clear inline feedback (the button's own icon/label
// swap right below), so a success toast would double-notify.
if (state.value === 'failed') toasts.push({ kind: 'error', message: 'Could not copy the snippet.' })
clearTimeout(resetTimer)
resetTimer = setTimeout(() => {
state.value = 'idle'
}, 1500)
if (result === 'failed') toasts.push({ kind: 'error', message: 'Could not copy the snippet.' })
}
const BUTTON_LABEL: Record<typeof state.value, string> = {
@@ -44,8 +41,6 @@ const BUTTON_LABEL: Record<typeof state.value, string> = {
selected: 'Selected — press ⌘/Ctrl+C',
failed: 'Copy failed',
}
onUnmounted(() => clearTimeout(resetTimer))
</script>
<template>
@@ -0,0 +1,36 @@
<script setup lang="ts">
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
import { Button } from '@/components/ui/button'
import { useNavStore } from '@/stores/nav'
/**
* StatDisabledAlert (reuse-audit.md F3) is the ONE shared "a fleet
* statistic is switched off in the middleware config" hint: a warn Alert
* with a config-key `<code>` reference and an "Open Config" button that
* navigates to the Config page. Replaces the 5 hand-rolled copies in
* ProviderHealthPanel, ModelCatalogTable, ReliabilityPage,
* AttributionDrilldown and UserDetail — those differed only in title,
* config key and trailing purpose text (some dynamic, e.g. naming the
* selected provider), which is exactly what these three props carry.
*/
const props = defineProps<{
/** e.g. "Latency statistics are off" or "Per-user-model statistics are off" */
title: string
/** dotted config key shown as inline code, e.g. "admin.stats.latency" */
configKey: string
/** trailing sentence after "Enable <code>{configKey}</code> in the middleware config ", e.g. "to see fleet p50/p95." */
purpose: string
}>()
const nav = useNavStore()
</script>
<template>
<Alert variant="warn">
<AlertTitle>{{ props.title }}</AlertTitle>
<AlertDescription class="flex flex-wrap items-center gap-2">
<span>Enable <code class="font-mono text-xs">{{ props.configKey }}</code> in the middleware config {{ props.purpose }}</span>
<Button type="button" variant="outline" size="sm" @click="nav.goTo('config')">Open Config</Button>
</AlertDescription>
</Alert>
</template>
+39
View File
@@ -0,0 +1,39 @@
<script setup lang="ts">
/**
* StatItem (reuse-audit.md F6) is the ONE label-over-value stat cell every
* "Total"/overview grid in this panel used to hand-roll: a muted label,
* then either the value (default slot, rendered `text-lg font-semibold
* tabular-nums`, optionally tinted by `tone`) or, when `hint` is set, a
* small muted substitute line instead of a value — the "not enough data
* yet"/"pick a reference model" case every month-projection or run-out
* figure in this panel shows before it has enough data to report one.
*/
withDefaults(
defineProps<{
label: string
tone?: 'default' | 'destructive' | 'accent' | 'muted'
/** Shown as the value paragraph's `title` attribute — e.g. "Projected to exceed the configured cost/month limit." */
title?: string
/** A small muted line shown INSTEAD of the (default-slot) value — e.g. "not enough data yet". Omit to render the value normally. */
hint?: string
}>(),
{ tone: 'default', title: undefined, hint: undefined },
)
const TONE_CLASS: Record<'default' | 'destructive' | 'accent' | 'muted', string | undefined> = {
default: undefined,
destructive: 'text-destructive',
accent: 'text-chart-requests',
muted: 'text-muted-foreground',
}
</script>
<template>
<div>
<p class="text-muted-foreground">{{ label }}</p>
<p v-if="hint" class="text-sm text-muted-foreground">{{ hint }}</p>
<p v-else class="text-lg font-semibold tabular-nums" :class="TONE_CLASS[tone]" :title="title">
<slot />
</p>
</div>
</template>
+6 -7
View File
@@ -3,10 +3,9 @@ import { computed, ref, watch } from 'vue'
import ClampedRangeNotice from '@/components/ClampedRangeNotice.vue'
import DataTable from '@/components/DataTable.vue'
import ErrorState from '@/components/ErrorState.vue'
import SkeletonTable from '@/components/SkeletonTable.vue'
import LoadStateView from '@/components/LoadStateView.vue'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { adminFetch } from '@/lib/api'
import { adminFetch, messageOf } from '@/lib/api'
import { loadState } from '@/lib/load-state'
import { dayOrMonthWindow, totalsUrl } from '@/lib/range'
import { targetCallerColumns } from '@/lib/target-columns'
@@ -72,7 +71,7 @@ async function load(): Promise<void> {
loadedOk.value = true
} catch (err) {
if (requestId !== reqId) return
error.value = err instanceof Error ? err.message : String(err)
error.value = messageOf(err)
} finally {
if (requestId === reqId) loading.value = false
}
@@ -98,9 +97,9 @@ const callersLoadState = computed(() => loadState({ loading: loading.value, hasD
</CardHeader>
<CardContent class="flex flex-col gap-3">
<ClampedRangeNotice :window="filters.window" :span="filters.span" />
<SkeletonTable v-if="callersLoadState === 'skeleton'" :rows="5" :cols="columns.length" />
<ErrorState v-else-if="callersLoadState === 'error'" :message="error" :on-retry="load" />
<DataTable v-else :columns="columns" :data="rows" empty-message="no callers recorded in this window" />
<LoadStateView :state="callersLoadState" :error="error" :on-retry="load" skeleton="table" :rows="5" :cols="columns.length">
<DataTable :columns="columns" :data="rows" empty-message="no callers recorded in this window" />
</LoadStateView>
</CardContent>
</Card>
</template>
+41 -66
View File
@@ -2,31 +2,30 @@
import { faArrowLeft } from '@fortawesome/free-solid-svg-icons'
import { computed, onMounted, onUnmounted, watch } from 'vue'
import BudgetRatioBars from '@/components/BudgetRatioBars.vue'
import ChartCard from '@/components/ChartCard.vue'
import ClampedRangeNotice from '@/components/ClampedRangeNotice.vue'
import CompactNumber from '@/components/CompactNumber.vue'
import EmptyState from '@/components/EmptyState.vue'
import ErrorState from '@/components/ErrorState.vue'
import ModelChip from '@/components/ModelChip.vue'
import SkeletonChart from '@/components/SkeletonChart.vue'
import SkeletonTable from '@/components/SkeletonTable.vue'
import TimeSeriesChart from '@/components/TimeSeriesChart.vue'
import StatDisabledAlert from '@/components/StatDisabledAlert.vue'
import StatItem from '@/components/StatItem.vue'
import TryLongerRangeButton from '@/components/TryLongerRangeButton.vue'
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
import UsageBar from '@/components/UsageBar.vue'
import { useNow } from '@/composables/useNow'
import { runOutDate as computeRunOutDate } from '@/lib/burndown'
import { formatBucketLabel, formatCost, formatExactInt, formatLimits, formatTimestamp } from '@/lib/format'
import { formatBucketLabel, formatCost, formatLimits, formatShortDate, formatTimestamp } from '@/lib/format'
import { loadState } from '@/lib/load-state'
import { BUDGET_RATIO_LABEL, budgetRatios } from '@/lib/usage-bars'
import { budgetRatios, usdToMicros } from '@/lib/usage-bars'
import { useConsumersStore } from '@/stores/consumers'
import { useDashboardStore } from '@/stores/dashboard'
import { useEventsStore } from '@/stores/events'
import { useFiltersStore } from '@/stores/filters'
import { useNavStore } from '@/stores/nav'
/**
* UserDetail (redesign-plan.md section 3.4) is the Consumers page's
@@ -42,7 +41,6 @@ const dashboard = useDashboardStore()
const consumers = useConsumersStore()
const events = useEventsStore()
const filters = useFiltersStore()
const nav = useNavStore()
const { now, start: startClock, stop: stopClock } = useNow()
onMounted(startClock)
onUnmounted(stopClock)
@@ -149,11 +147,6 @@ const recentErrors = computed(() => events.events.filter((e) => e.user === props
const ratios = computed(() => (entry.value ? budgetRatios(entry.value) : []))
function budgetValueText(id: string, used: number, limit: number): string {
if (id.startsWith('cost')) return `${formatCost(used)} / ${formatCost(limit)}`
return `${formatExactInt(used)} / ${formatExactInt(limit)}`
}
/**
* runOutDate reuses lib/burndown.ts's own runOutDate — the SAME function
* BurnDownChart.vue projects the Spend page's burn-down line with — rather
@@ -163,7 +156,7 @@ function budgetValueText(id: string, used: number, limit: number): string {
const runOutDate = computed(() => {
const limitUsd = entry.value?.limits?.costPerMonthUSD
if (!entry.value || !limitUsd) return null
return computeRunOutDate(entry.value.costPerMonthMicroUsd, Math.round(limitUsd * 1_000_000), now.value)
return computeRunOutDate(entry.value.costPerMonthMicroUsd, usdToMicros(limitUsd), now.value)
})
</script>
@@ -191,18 +184,9 @@ const runOutDate = computed(() => {
No usage scope for this user yet — it appears after their first request.
</CardContent>
<CardContent v-else class="grid grid-cols-2 gap-4 text-sm sm:grid-cols-4">
<div>
<p class="text-muted-foreground">Requests/day</p>
<p class="text-lg font-semibold tabular-nums"><CompactNumber :value="entry.requestsPerDay" /></p>
</div>
<div>
<p class="text-muted-foreground">Cost/day</p>
<p class="text-lg font-semibold tabular-nums">{{ formatCost(entry.costPerDayMicroUsd) }}</p>
</div>
<div>
<p class="text-muted-foreground">Cost/month</p>
<p class="text-lg font-semibold tabular-nums">{{ formatCost(entry.costPerMonthMicroUsd) }}</p>
</div>
<StatItem label="Requests/day"><CompactNumber :value="entry.requestsPerDay" /></StatItem>
<StatItem label="Cost/day">{{ formatCost(entry.costPerDayMicroUsd) }}</StatItem>
<StatItem label="Cost/month">{{ formatCost(entry.costPerMonthMicroUsd) }}</StatItem>
<div>
<p class="text-muted-foreground">Limits</p>
<p class="text-sm">{{ formatLimits(entry.limits) }}</p>
@@ -213,41 +197,33 @@ const runOutDate = computed(() => {
<Card v-if="entry && ratios.length">
<CardHeader>
<CardTitle>Headroom</CardTitle>
<CardDescription v-if="runOutDate">At the current pace, cost/month runs out around {{ formatTimestamp(runOutDate.toISOString()) }}.</CardDescription>
<CardDescription v-if="runOutDate">At the current pace, cost/month runs out around {{ formatShortDate(runOutDate) }}.</CardDescription>
</CardHeader>
<CardContent class="flex flex-wrap items-center gap-x-4 gap-y-1.5">
<span v-for="ratio in ratios" :key="ratio.id" class="inline-flex items-center gap-1.5 text-xs text-muted-foreground">
{{ BUDGET_RATIO_LABEL[ratio.id] }}
<UsageBar
:ratio="ratio.ratio"
:label="BUDGET_RATIO_LABEL[ratio.id]"
:value-text="budgetValueText(ratio.id, ratio.used, ratio.limit)"
<CardContent>
<BudgetRatioBars :ratios="ratios" />
</CardContent>
</Card>
<ChartCard
title="Requests over time"
:state="reqState"
:error="detail?.reqError ?? ''"
:on-retry="loadDetail"
:labels="reqLabels"
:datasets="reqDatasets"
:aria-label="`Requests over time for ${userId}`"
/>
</span>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Requests over time</CardTitle>
</CardHeader>
<CardContent>
<SkeletonChart v-if="reqState === 'skeleton'" />
<ErrorState v-else-if="reqState === 'error'" :message="detail?.reqError ?? ''" :on-retry="loadDetail" />
<TimeSeriesChart v-else :labels="reqLabels" :datasets="reqDatasets" :ariaLabel="`Requests over time for ${userId}`" />
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Cost over time</CardTitle>
</CardHeader>
<CardContent>
<SkeletonChart v-if="costState === 'skeleton'" />
<ErrorState v-else-if="costState === 'error'" :message="detail?.costError ?? ''" :on-retry="loadDetail" />
<TimeSeriesChart v-else :labels="costLabels" :datasets="costDatasets" :value-formatter="formatCost" :ariaLabel="`Cost over time for ${userId}`" />
</CardContent>
</Card>
<ChartCard
title="Cost over time"
:state="costState"
:error="detail?.costError ?? ''"
:on-retry="loadDetail"
:labels="costLabels"
:datasets="costDatasets"
:value-formatter="formatCost"
:aria-label="`Cost over time for ${userId}`"
/>
<Card>
<CardHeader>
@@ -255,13 +231,12 @@ const runOutDate = computed(() => {
</CardHeader>
<CardContent class="flex flex-col gap-3">
<ClampedRangeNotice :window="filters.window" :span="filters.span" />
<Alert v-if="showUserModelHint" variant="warn">
<AlertTitle>Per-user-model statistics are off</AlertTitle>
<AlertDescription class="flex flex-wrap items-center gap-2">
<span>Enable <code class="font-mono text-xs">admin.stats.userModel</code> in the middleware config to see a per-model breakdown for this user.</span>
<Button type="button" variant="outline" size="sm" @click="nav.goTo('config')">Open Config</Button>
</AlertDescription>
</Alert>
<StatDisabledAlert
v-if="showUserModelHint"
title="Per-user-model statistics are off"
config-key="admin.stats.userModel"
purpose="to see a per-model breakdown for this user."
/>
<SkeletonTable v-else-if="modelState === 'skeleton'" :rows="3" :cols="3" />
<ErrorState v-else-if="modelState === 'error'" :message="detail?.modelError ?? ''" :on-retry="loadDetail" />
<EmptyState v-else-if="!modelRows.length" title="No traffic in this range.">
@@ -300,7 +275,7 @@ const runOutDate = computed(() => {
<p class="text-muted-foreground">{{ event.message }}</p>
</li>
</ul>
<p v-else class="py-6 text-center text-sm text-muted-foreground">none in the current event window</p>
<EmptyState v-else title="No recent errors in the current event window." />
</CardContent>
</Card>
+16 -47
View File
@@ -1,11 +1,12 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import TargetPicker from '@/components/forms/TargetPicker.vue'
import SnippetBlock from '@/components/SnippetBlock.vue'
import { Alert, AlertDescription } from '@/components/ui/alert'
import { Input } from '@/components/ui/input'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { groupGrantSnippet, isValidName, isValidUserName, userGrantJsonFragment, validatePersonalGrant } from '@/lib/snippets'
import { useTargetPicker } from '@/composables/useTargetPicker'
import { groupGrantSnippet, parseList, userGrantJsonFragment, validatePersonalGrant } from '@/lib/snippets'
/**
* GrantForm (ChangeHelper, redesign-plan.md section 3.4) builds a group's
@@ -23,35 +24,20 @@ const props = defineProps<{
userNames: string[]
}>()
const targetKind = ref<'group' | 'user'>('group')
const targetName = ref('')
const { targetKind, targetName, targetNames, nameValid, snippetLabel } = useTargetPicker(
() => props.groupNames,
() => props.userNames,
)
const providersRaw = ref('')
const modelsRaw = ref('')
const mcpServersRaw = ref('')
const agentsRaw = ref('')
/** parseList splits a comma-separated field into trimmed, non-empty, de-duplicated entries — the one shared parser every list field below uses. */
function parseList(raw: string): string[] {
const seen = new Set<string>()
for (const part of raw.split(',')) {
const trimmed = part.trim()
if (trimmed) seen.add(trimmed)
}
return Array.from(seen)
}
const providers = computed(() => parseList(providersRaw.value))
const models = computed(() => parseList(modelsRaw.value))
const mcpServers = computed(() => parseList(mcpServersRaw.value))
const agents = computed(() => parseList(agentsRaw.value))
const targetNames = computed(() => (targetKind.value === 'group' ? props.groupNames : props.userNames))
// A group name follows NAME_PATTERN (auth.go's configNamePattern — a
// route-path-segment safety rule); a user name only needs to be
// non-empty (auth.go's buildEntry, no character restriction at all —
// P3 item 27). isValidUserName, not isValidName, for targetKind === 'user'.
const nameValid = computed(() => (targetKind.value === 'group' ? isValidName(targetName.value.trim()) : isValidUserName(targetName.value)))
// validatePersonalGrant is a USER-ONLY rule (lib/snippets.ts's own doc
// comment) — a group's own `providers: []` legitimately means "every
// provider" and Go applies it with no such check at group-construction
@@ -81,27 +67,14 @@ const snippet = computed<string | null>(() => {
<template>
<div class="flex flex-col gap-4">
<div class="grid grid-cols-1 gap-3 sm:grid-cols-2">
<div class="flex flex-col gap-1">
<label id="grant-target-kind-label" class="text-xs font-medium text-muted-foreground">Target</label>
<Select :model-value="targetKind" @update:model-value="(v) => (targetKind = v as 'group' | 'user')">
<SelectTrigger aria-labelledby="grant-target-kind-label"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="group">Group</SelectItem>
<SelectItem value="user">User (personal grant, existing users.json entry)</SelectItem>
</SelectContent>
</Select>
</div>
<div class="flex flex-col gap-1">
<label id="grant-target-name-label" class="text-xs font-medium text-muted-foreground">{{ targetKind === 'group' ? 'Group name' : 'User name' }}</label>
<Select :model-value="targetName" @update:model-value="(v) => (targetName = String(v))">
<SelectTrigger aria-labelledby="grant-target-name-label"><SelectValue placeholder="Select…" /></SelectTrigger>
<SelectContent>
<SelectItem v-for="name in targetNames" :key="name" :value="name">{{ name }}</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<TargetPicker
:target-kind="targetKind"
:target-name="targetName"
:target-names="targetNames"
user-option-label="User (personal grant, existing users.json entry)"
@update:target-kind="targetKind = $event"
@update:target-name="targetName = $event"
/>
<div class="grid grid-cols-1 gap-3 sm:grid-cols-2">
<div class="flex flex-col gap-1">
@@ -128,11 +101,7 @@ const snippet = computed<string | null>(() => {
<AlertDescription>{{ grantError }}</AlertDescription>
</Alert>
<SnippetBlock
v-if="snippet"
:label="targetKind === 'group' ? 'Paste into middleware.yaml' : `Merge into ${targetName}’s users.json line`"
:text="snippet"
/>
<SnippetBlock v-if="snippet" :label="snippetLabel" :text="snippet" />
<p v-else class="text-sm text-muted-foreground">Pick a target and at least one list to build a snippet.</p>
</div>
</template>
+17 -39
View File
@@ -1,11 +1,12 @@
<script setup lang="ts">
import { computed, reactive, ref } from 'vue'
import { computed, reactive } from 'vue'
import TargetPicker from '@/components/forms/TargetPicker.vue'
import SnippetBlock from '@/components/SnippetBlock.vue'
import { Alert, AlertDescription } from '@/components/ui/alert'
import { Input } from '@/components/ui/input'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { groupLimitsSnippet, isValidName, isValidNonNegative, isValidNonNegativeInt, isValidUserName, userLimitsJsonFragment } from '@/lib/snippets'
import { useTargetPicker } from '@/composables/useTargetPicker'
import { groupLimitsSnippet, isValidNonNegative, isValidNonNegativeInt, userLimitsJsonFragment } from '@/lib/snippets'
import type { LimitsConfig } from '@/types/api'
/**
@@ -22,8 +23,10 @@ const props = defineProps<{
userNames: string[]
}>()
const targetKind = ref<'group' | 'user'>('group')
const targetName = ref('')
const { targetKind, targetName, targetNames, nameValid, snippetLabel } = useTargetPicker(
() => props.groupNames,
() => props.userNames,
)
/** INTEGER_FIELDS is every LimitsConfig field Go decodes as `int64` (llmgateway.go) — requestsPerMinute/requestsPerDay/tokensPerDay/tokensPerMonth reject a fractional value like "1.5" at json.Unmarshal outright (P2 item 12), unlike costPerDayUSD/costPerMonthUSD, which are `float64`. */
const INTEGER_FIELDS = new Set<keyof LimitsConfig>(['requestsPerMinute', 'requestsPerDay', 'tokensPerDay', 'tokensPerMonth'])
@@ -51,8 +54,6 @@ const raw = reactive<Record<keyof LimitsConfig, string>>({
costPerMonthUSD: '',
})
const targetNames = computed(() => (targetKind.value === 'group' ? props.groupNames : props.userNames))
/** invalidFields lists every field whose entered text is non-empty but fails its own domain check (fieldValid above) — surfaced individually rather than one generic "invalid input" message, so the operator can see exactly which field to fix. Split by INTEGER_FIELDS so the error text is accurate for both: a fraction is invalid for requestsPerMinute etc. but perfectly valid for costPerDayUSD/costPerMonthUSD. */
const invalidFields = computed(() =>
FIELDS.filter(({ key }) => raw[key].trim() !== '' && !fieldValid(key, Number(raw[key]))).map((f) => f.label),
@@ -73,12 +74,6 @@ const limits = computed<LimitsConfig>(() => {
return out
})
// A group name follows NAME_PATTERN (auth.go's configNamePattern — a
// route-path-segment safety rule); a user name only needs to be
// non-empty (auth.go's buildEntry, no character restriction at all —
// P3 item 27). isValidUserName, not isValidName, for targetKind === 'user'.
const nameValid = computed(() => (targetKind.value === 'group' ? isValidName(targetName.value.trim()) : isValidUserName(targetName.value)))
const snippet = computed<string | null>(() => {
if (!nameValid.value || invalidFields.value.length > 0) return null
if (Object.keys(limits.value).length === 0) return null
@@ -90,27 +85,14 @@ const snippet = computed<string | null>(() => {
<template>
<div class="flex flex-col gap-4">
<div class="grid grid-cols-1 gap-3 sm:grid-cols-2">
<div class="flex flex-col gap-1">
<label id="limits-target-kind-label" class="text-xs font-medium text-muted-foreground">Target</label>
<Select :model-value="targetKind" @update:model-value="(v) => (targetKind = v as 'group' | 'user')">
<SelectTrigger aria-labelledby="limits-target-kind-label"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="group">Group</SelectItem>
<SelectItem value="user">User (existing users.json entry)</SelectItem>
</SelectContent>
</Select>
</div>
<div class="flex flex-col gap-1">
<label id="limits-target-name-label" class="text-xs font-medium text-muted-foreground">{{ targetKind === 'group' ? 'Group name' : 'User name' }}</label>
<Select :model-value="targetName" @update:model-value="(v) => (targetName = String(v))">
<SelectTrigger aria-labelledby="limits-target-name-label"><SelectValue placeholder="Select…" /></SelectTrigger>
<SelectContent>
<SelectItem v-for="name in targetNames" :key="name" :value="name">{{ name }}</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<TargetPicker
:target-kind="targetKind"
:target-name="targetName"
:target-names="targetNames"
user-option-label="User (existing users.json entry)"
@update:target-kind="targetKind = $event"
@update:target-name="targetName = $event"
/>
<div class="grid grid-cols-1 gap-3 sm:grid-cols-3">
<div v-for="field in FIELDS" :key="field.key" class="flex flex-col gap-1">
@@ -133,11 +115,7 @@ const snippet = computed<string | null>(() => {
</AlertDescription>
</Alert>
<SnippetBlock
v-if="snippet"
:label="targetKind === 'group' ? 'Paste into middleware.yaml' : `Merge into ${targetName}’s users.json line`"
:text="snippet"
/>
<SnippetBlock v-if="snippet" :label="snippetLabel" :text="snippet" />
<p v-else class="text-sm text-muted-foreground">Pick a target and at least one limit to build a snippet.</p>
</div>
</template>
@@ -0,0 +1,62 @@
<script setup lang="ts">
import { useId } from 'vue'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import type { GrantTargetKind } from '@/composables/useTargetPicker'
/**
* TargetPicker (reuse-audit.md F12) is the "Target kind, then Group or
* User name" two-Select block GrantForm.vue and LimitsForm.vue each
* hand-rolled — pairs with the composables/useTargetPicker.ts composable,
* which owns the state/validation this component only renders.
* `userOptionLabel` stays a prop rather than a hardcoded string: GrantForm
* ("User (personal grant, existing users.json entry)") and LimitsForm
* ("User (existing users.json entry)") word the "user" option differently,
* and unifying that wording is outside this refactor's scope.
*/
const props = defineProps<{
targetKind: GrantTargetKind
targetName: string
targetNames: string[]
userOptionLabel: string
}>()
const emit = defineEmits<{
'update:targetKind': [value: GrantTargetKind]
'update:targetName': [value: string]
}>()
const kindLabelId = `target-picker-kind-${useId()}`
const nameLabelId = `target-picker-name-${useId()}`
function onKindUpdate(value: unknown): void {
if (value === 'group' || value === 'user') emit('update:targetKind', value)
}
function onNameUpdate(value: unknown): void {
emit('update:targetName', String(value))
}
</script>
<template>
<div class="grid grid-cols-1 gap-3 sm:grid-cols-2">
<div class="flex flex-col gap-1">
<label :id="kindLabelId" class="text-xs font-medium text-muted-foreground">Target</label>
<Select :model-value="targetKind" @update:model-value="onKindUpdate">
<SelectTrigger :aria-labelledby="kindLabelId"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="group">Group</SelectItem>
<SelectItem value="user">{{ props.userOptionLabel }}</SelectItem>
</SelectContent>
</Select>
</div>
<div class="flex flex-col gap-1">
<label :id="nameLabelId" class="text-xs font-medium text-muted-foreground">{{ targetKind === 'group' ? 'Group name' : 'User name' }}</label>
<Select :model-value="targetName" @update:model-value="onNameUpdate">
<SelectTrigger :aria-labelledby="nameLabelId"><SelectValue placeholder="Select…" /></SelectTrigger>
<SelectContent>
<SelectItem v-for="name in targetNames" :key="name" :value="name">{{ name }}</SelectItem>
</SelectContent>
</Select>
</div>
</div>
</template>
+11 -20
View File
@@ -1,12 +1,12 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import LabeledSelect from '@/components/LabeledSelect.vue'
import SnippetBlock from '@/components/SnippetBlock.vue'
import { Alert, AlertDescription } from '@/components/ui/alert'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { API_KEY_PLACEHOLDER, generateApiKey, isValidUserName, userJsonLine, validatePersonalGrant } from '@/lib/snippets'
import { API_KEY_PLACEHOLDER, generateApiKey, isValidUserName, parseList, userJsonLine, validatePersonalGrant } from '@/lib/snippets'
import { useToastsStore } from '@/stores/toasts'
/**
@@ -21,6 +21,8 @@ import { useToastsStore } from '@/stores/toasts'
*/
const props = defineProps<{ groupNames: string[] }>()
const groupOptions = computed(() => props.groupNames.map((g) => ({ value: g, label: g })))
const name = ref('')
const apiKey = ref(API_KEY_PLACEHOLDER)
const primaryGroup = ref('')
@@ -51,15 +53,6 @@ function generate(): void {
}
}
function parseList(raw: string): string[] {
const seen = new Set<string>()
for (const part of raw.split(',')) {
const trimmed = part.trim()
if (trimmed) seen.add(trimmed)
}
return Array.from(seen)
}
const extraGroups = computed(() => parseList(extraGroupsRaw.value))
const providers = computed(() => parseList(providersRaw.value))
const models = computed(() => parseList(modelsRaw.value))
@@ -91,15 +84,13 @@ const snippet = computed<string | null>(() => {
<label for="user-name" class="text-xs font-medium text-muted-foreground">Name</label>
<Input id="user-name" v-model="name" placeholder="alice" />
</div>
<div class="flex flex-col gap-1">
<label id="user-group-label" class="text-xs font-medium text-muted-foreground">Primary group</label>
<Select :model-value="primaryGroup" @update:model-value="(v) => (primaryGroup = String(v))">
<SelectTrigger aria-labelledby="user-group-label"><SelectValue placeholder="Select…" /></SelectTrigger>
<SelectContent>
<SelectItem v-for="g in props.groupNames" :key="g" :value="g">{{ g }}</SelectItem>
</SelectContent>
</Select>
</div>
<LabeledSelect
label="Primary group"
:model-value="primaryGroup"
:options="groupOptions"
placeholder="Select…"
@update:model-value="primaryGroup = $event"
/>
</div>
<div class="flex flex-col gap-1">
@@ -0,0 +1,45 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { useCopyFeedback } from './useCopyFeedback'
describe('useCopyFeedback', () => {
beforeEach(() => {
vi.useFakeTimers()
})
afterEach(() => {
vi.useRealTimers()
})
it('starts idle', () => {
const { state } = useCopyFeedback()
expect(state.value).toBe('idle')
})
it.each(['copied', 'selected', 'failed'] as const)('set(%s) records that outcome', (outcome) => {
const { state, set } = useCopyFeedback()
set(outcome)
expect(state.value).toBe(outcome)
})
it('reverts to idle after 1500ms', () => {
const { state, set } = useCopyFeedback()
set('copied')
vi.advanceTimersByTime(1499)
expect(state.value).toBe('copied')
vi.advanceTimersByTime(1)
expect(state.value).toBe('idle')
})
it('a second set() before the first reset restarts the 1500ms window rather than stacking timers', () => {
const { state, set } = useCopyFeedback()
set('copied')
vi.advanceTimersByTime(1000)
set('selected')
vi.advanceTimersByTime(1000)
// 2000ms since the first set(), but only 1000ms since the second —
// still showing the second outcome, not yet reset.
expect(state.value).toBe('selected')
vi.advanceTimersByTime(500)
expect(state.value).toBe('idle')
})
})
+42
View File
@@ -0,0 +1,42 @@
import { onUnmounted, ref, type Ref } from 'vue'
/** RESET_MS is how long a copy-feedback state (`copied`/`selected`/`failed`) stays visible before reverting to `idle` — SnippetBlock.vue's and ModelChip.vue's own identical 1500ms. */
const RESET_MS = 1500
/** CopyFeedbackState mirrors lib/clipboard.ts's copyText own return union, plus the resting `idle` state neither SnippetBlock.vue nor ModelChip.vue had a name for before this composable. */
export type CopyFeedbackState = 'idle' | 'copied' | 'selected' | 'failed'
export interface UseCopyFeedback {
/** The current feedback state — drives a caller's own icon/label swap. */
state: Ref<CopyFeedbackState>
/** Records a copyText() outcome and schedules the reset-to-idle timer, clearing any still-pending one first (a rapid double-click restarts the window rather than stacking two timers). */
set: (result: 'copied' | 'selected' | 'failed') => void
}
/**
* useCopyFeedback (reuse-audit.md F10) is the ONE "show a transient copy
* outcome, then revert to idle after 1500ms" composable SnippetBlock.vue
* and ModelChip.vue used to each hand-roll — the same `state` ref/
* `resetTimer`/`clearTimeout`-on-unmount triple, with the identical
* 1500ms magic number declared independently in both. Calls
* `onUnmounted` itself (this composable is only ever invoked from a
* component's own `setup()`/`<script setup>`, same as every other
* composable in this panel), so a caller no longer needs its own cleanup
* hook just for this timer.
*/
export function useCopyFeedback(): UseCopyFeedback {
const state = ref<CopyFeedbackState>('idle')
let resetTimer: ReturnType<typeof setTimeout> | undefined
function set(result: 'copied' | 'selected' | 'failed'): void {
state.value = result
clearTimeout(resetTimer)
resetTimer = setTimeout(() => {
state.value = 'idle'
}, RESET_MS)
}
onUnmounted(() => clearTimeout(resetTimer))
return { state, set }
}
@@ -0,0 +1,47 @@
import { describe, expect, it } from 'vitest'
import { useTargetPicker } from './useTargetPicker'
describe('useTargetPicker', () => {
it('defaults to targetKind "group" with an empty targetName', () => {
const picker = useTargetPicker(() => ['eng'], () => ['alice'])
expect(picker.targetKind.value).toBe('group')
expect(picker.targetName.value).toBe('')
})
it('targetNames reads groupNames() while targetKind is "group", userNames() while "user"', () => {
const picker = useTargetPicker(() => ['eng', 'ops'], () => ['alice', 'bob'])
expect(picker.targetNames.value).toEqual(['eng', 'ops'])
picker.targetKind.value = 'user'
expect(picker.targetNames.value).toEqual(['alice', 'bob'])
})
it('nameValid applies isValidName for a group target', () => {
const picker = useTargetPicker(() => [], () => [])
picker.targetName.value = 'valid-name.1'
expect(picker.nameValid.value).toBe(true)
picker.targetName.value = 'has spaces'
expect(picker.nameValid.value).toBe(false)
})
it('nameValid applies isValidUserName (non-empty only) for a user target', () => {
const picker = useTargetPicker(() => [], () => [])
picker.targetKind.value = 'user'
picker.targetName.value = 'has spaces are fine for a user'
expect(picker.nameValid.value).toBe(true)
picker.targetName.value = ' '
expect(picker.nameValid.value).toBe(false)
})
it('snippetLabel names middleware.yaml for a group target', () => {
const picker = useTargetPicker(() => [], () => [])
expect(picker.snippetLabel.value).toBe('Paste into middleware.yaml')
})
it('snippetLabel names the user\'s users.json line, with the current target name, for a user target', () => {
const picker = useTargetPicker(() => [], () => [])
picker.targetKind.value = 'user'
picker.targetName.value = 'alice'
expect(picker.snippetLabel.value).toBe('Merge into alice’s users.json line')
})
})
+42
View File
@@ -0,0 +1,42 @@
import { computed, ref, type ComputedRef, type Ref } from 'vue'
import { isValidName, isValidUserName } from '@/lib/snippets'
/** GrantTargetKind — named to avoid colliding with lib/target-columns.ts's own unrelated TargetKind ('mcp' | 'agent'). */
export type GrantTargetKind = 'group' | 'user'
export interface UseTargetPicker {
targetKind: Ref<GrantTargetKind>
targetName: Ref<string>
/** Every configured name for the CURRENT targetKind — groupNames() while 'group', userNames() while 'user'. */
targetNames: ComputedRef<string[]>
/** A group name follows NAME_PATTERN (isValidName); a user name only needs to be non-empty (isValidUserName) — auth.go's own two different name rules. */
nameValid: ComputedRef<boolean>
/** The SnippetBlock label every ChangeHelper form using this picker shows once its snippet is ready. */
snippetLabel: ComputedRef<string>
}
/**
* useTargetPicker (reuse-audit.md F12) is the ONE "Target kind, then Group
* or User name" picker GrantForm.vue and LimitsForm.vue used to each
* hand-roll — the same targetKind/targetName refs, targetNames/nameValid
* computeds (down to the identical doc comments), and SnippetBlock label
* ternary. `groupNames`/`userNames` are getters, not plain arrays, so a
* caller can pass `() => props.groupNames` and keep this reactive to a
* prop that changes after mount (destructuring `props.groupNames`
* directly would freeze it at the value read at call time).
*/
export function useTargetPicker(groupNames: () => string[], userNames: () => string[]): UseTargetPicker {
const targetKind = ref<GrantTargetKind>('group')
const targetName = ref('')
const targetNames = computed(() => (targetKind.value === 'group' ? groupNames() : userNames()))
const nameValid = computed(() => (targetKind.value === 'group' ? isValidName(targetName.value.trim()) : isValidUserName(targetName.value)))
const snippetLabel = computed(() =>
targetKind.value === 'group' ? 'Paste into middleware.yaml' : `Merge into ${targetName.value}’s users.json line`,
)
return { targetKind, targetName, targetNames, nameValid, snippetLabel }
}
+26 -1
View File
@@ -8,7 +8,7 @@
import { createPinia, setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { AdminApiError, adminFetch } from './api'
import { AdminApiError, adminFetch, isAuthRejection, messageOf } from './api'
import { useAuthStore } from '@/stores/auth'
function jsonResponse(status: number, body: unknown): Response {
@@ -76,3 +76,28 @@ describe('adminFetch', () => {
expect(auth.error).toBe('invalid key, or not an admin')
})
})
describe('messageOf', () => {
it('returns an Error instance\'s own message', () => {
expect(messageOf(new Error('boom'))).toBe('boom')
})
it('stringifies a non-Error thrown value', () => {
expect(messageOf('plain string')).toBe('plain string')
expect(messageOf(404)).toBe('404')
expect(messageOf(null)).toBe('null')
})
})
describe('isAuthRejection', () => {
it('is true for an AdminApiError with status 401 or 403', () => {
expect(isAuthRejection(new AdminApiError('nope', 401))).toBe(true)
expect(isAuthRejection(new AdminApiError('nope', 403))).toBe(true)
})
it('is false for any other AdminApiError status or a non-AdminApiError value', () => {
expect(isAuthRejection(new AdminApiError('server error', 500))).toBe(false)
expect(isAuthRejection(new Error('plain'))).toBe(false)
expect(isAuthRejection('nope')).toBe(false)
})
})
+23
View File
@@ -61,3 +61,26 @@ export async function adminFetch<T>(path: string): Promise<T> {
}
return (await resp.json()) as T
}
/**
* messageOf (reuse-audit.md F4 step 1) extracts a human-readable message
* from a caught value of unknown shape: an Error's own `.message`, or the
* stringified value otherwise (a thrown string, object, etc.). The ONE
* shared implementation — every Pinia store's fetch action and every
* component with a local latest-request-wins fetch used to hand-roll this
* same ternary in its `catch` block.
*/
export function messageOf(err: unknown): string {
return err instanceof Error ? err.message : String(err)
}
/**
* isAuthRejection (reuse-audit.md F4 step 1) reports whether a caught
* value is an AdminApiError for a 401/403 — i.e. adminFetch has already
* rejected the stored key (authStore.reject) for this failure, so the
* caller's own `catch` block should return early instead of also setting
* its local `error` state with the same rejection.
*/
export function isAuthRejection(err: unknown): boolean {
return err instanceof AdminApiError && (err.status === 401 || err.status === 403)
}
+27
View File
@@ -0,0 +1,27 @@
import { describe, expect, it } from 'vitest'
import { BREAKDOWN_PALETTE, CHART_SERIES_COLORS, seriesColor } from './chart-palette'
describe('BREAKDOWN_PALETTE', () => {
it('starts with the same four colors as CHART_SERIES_COLORS, in the same order', () => {
expect(BREAKDOWN_PALETTE.slice(0, 4)).toEqual(CHART_SERIES_COLORS)
})
it('has 8 entries total', () => {
expect(BREAKDOWN_PALETTE).toHaveLength(8)
})
})
describe('seriesColor', () => {
it('cycles through the default palette (CHART_SERIES_COLORS) by index', () => {
expect(seriesColor(0)).toBe(CHART_SERIES_COLORS[0])
expect(seriesColor(CHART_SERIES_COLORS.length)).toBe(CHART_SERIES_COLORS[0])
expect(seriesColor(CHART_SERIES_COLORS.length + 1)).toBe(CHART_SERIES_COLORS[1])
})
it('cycles through an explicitly passed palette (e.g. BREAKDOWN_PALETTE) instead', () => {
expect(seriesColor(0, BREAKDOWN_PALETTE)).toBe(BREAKDOWN_PALETTE[0])
expect(seriesColor(7, BREAKDOWN_PALETTE)).toBe(BREAKDOWN_PALETTE[7])
expect(seriesColor(8, BREAKDOWN_PALETTE)).toBe(BREAKDOWN_PALETTE[0])
})
})
+49
View File
@@ -0,0 +1,49 @@
// reuse-audit.md F13: the ONE chart colour palette module. Two call sites
// used to each own a palette + its own cycling function — stores/
// reliability.ts's RELIABILITY_SERIES_COLORS (a UI concern that had drifted
// into a Pinia store) and pages/SpendPage.vue's BREAKDOWN_PALETTE (the
// identical first four colours, plus four more) — this module is their one
// shared home.
/**
* CHART_SERIES_COLORS cycles through main.css's four existing --chart-*
* tokens (assets/main.css is owned by WP-E — this module does not add a
* fifth) — used to colour the Reliability page's per-provider error-rate/
* timeout/failover overlay charts. With more than four configured
* providers, colors repeat — a known, documented limitation rather than a
* silent one.
*/
export const CHART_SERIES_COLORS = ['--chart-requests', '--chart-tokens-in', '--chart-tokens-out', '--chart-cost'] as const
/**
* BREAKDOWN_PALETTE cycles a colorblind-safe (Okabe & Ito, 2008) 8-color
* qualitative set across the Spend page's breakdown chart's per-model/
* provider/group series. The first four reuse CHART_SERIES_COLORS (already
* that same palette's requests/tokens-in/tokens-out/cost members); the
* remaining four (--chart-series-a..d) extend it, defined alongside them in
* assets/main.css — main.css's original four tokens were sized for four
* FIXED semantic metrics, not an open-ended breakdown series count, so this
* page needed four more, but every chart color token lives in the one
* design-tokens file (Tailwind-only rule), never a component-local <style>
* block. Beyond 8 series (e.g. a fleet with more than 8 configured groups)
* colors repeat — an accepted qualitative-palette limit, not a bug: legend
* labels remain the disambiguator past that point, the same tradeoff any
* categorical chart palette makes once it runs out of distinguishable hues.
*/
export const BREAKDOWN_PALETTE = [
...CHART_SERIES_COLORS,
'--chart-series-a',
'--chart-series-b',
'--chart-series-c',
'--chart-series-d',
] as const
/**
* seriesColor picks one entry from `palette` by index, cycling — defaults
* to CHART_SERIES_COLORS (the Reliability page's 4-color set); pass
* BREAKDOWN_PALETTE for the Spend page's 8-color breakdown chart, or any
* other palette a future chart needs.
*/
export function seriesColor(index: number, palette: readonly string[] = CHART_SERIES_COLORS): string {
return palette[index % palette.length]!
}
+75
View File
@@ -0,0 +1,75 @@
import { describe, expect, it } from 'vitest'
import { compactColumn, costColumn, EMPTY_CELL, optionalColumn, usageMetricColumns } from './columns'
interface Row {
requests: number
tokensIn: number
tokensOut: number
costMicroUsd: number
latencyMs?: number
}
describe('EMPTY_CELL', () => {
it('is the em dash', () => {
expect(EMPTY_CELL).toBe('—')
})
})
describe('compactColumn', () => {
it('carries the given id/header and right-aligns', () => {
const col = compactColumn<Row>('requests', 'Requests', (r) => r.requests)
expect(col.id).toBe('requests')
expect(col.header).toBe('Requests')
expect(col.meta).toEqual({ align: 'right' })
})
it('accessorFn reads the given field via the read function', () => {
const col = compactColumn<Row>('requests', 'Requests', (r) => r.requests) as unknown as { accessorFn: (r: Row) => number }
expect(col.accessorFn({ requests: 42, tokensIn: 0, tokensOut: 0, costMicroUsd: 0 })).toBe(42)
})
})
describe('costColumn', () => {
it('accessorFn reads the given field, unformatted', () => {
const col = costColumn<Row>('cost', 'Cost', (r) => r.costMicroUsd) as unknown as { accessorFn: (r: Row) => number }
expect(col.accessorFn({ requests: 0, tokensIn: 0, tokensOut: 0, costMicroUsd: 1_500_000 })).toBe(1_500_000)
})
})
describe('optionalColumn', () => {
it('accessorFn falls back to the default sentinel (0) for an undefined value', () => {
const col = optionalColumn<Row>('latency', 'Latency', (r) => r.latencyMs, String) as unknown as { accessorFn: (r: Row) => number }
expect(col.accessorFn({ requests: 0, tokensIn: 0, tokensOut: 0, costMicroUsd: 0 })).toBe(0)
})
it('accessorFn falls back to a caller-supplied sortSentinel for an undefined value', () => {
const col = optionalColumn<Row>('latency', 'Latency', (r) => r.latencyMs, String, { sortSentinel: -1 }) as unknown as {
accessorFn: (r: Row) => number
}
expect(col.accessorFn({ requests: 0, tokensIn: 0, tokensOut: 0, costMicroUsd: 0 })).toBe(-1)
})
it('accessorFn reads the real value when defined, ignoring the sentinel', () => {
const col = optionalColumn<Row>('latency', 'Latency', (r) => r.latencyMs, String, { sortSentinel: -1 }) as unknown as {
accessorFn: (r: Row) => number
}
expect(col.accessorFn({ requests: 0, tokensIn: 0, tokensOut: 0, costMicroUsd: 0, latencyMs: 120 })).toBe(120)
})
})
describe('usageMetricColumns', () => {
it('builds exactly requests/tokensIn/tokensOut/cost, in that order', () => {
const columns = usageMetricColumns<Row>()
expect(columns.map((c) => c.id)).toEqual(['requests', 'tokensIn', 'tokensOut', 'cost'])
})
it('each column\'s accessorFn reads its own field', () => {
const row: Row = { requests: 1, tokensIn: 2, tokensOut: 3, costMicroUsd: 4 }
const columns = usageMetricColumns<Row>() as unknown as { id: string; accessorFn: (r: Row) => number }[]
expect(columns.find((c) => c.id === 'requests')?.accessorFn(row)).toBe(1)
expect(columns.find((c) => c.id === 'tokensIn')?.accessorFn(row)).toBe(2)
expect(columns.find((c) => c.id === 'tokensOut')?.accessorFn(row)).toBe(3)
expect(columns.find((c) => c.id === 'cost')?.accessorFn(row)).toBe(4)
})
})
+87
View File
@@ -0,0 +1,87 @@
// reuse-audit.md F9: generic TanStack ColumnDef factories for the numeric-
// metric columns that repeat across every usage-shaped table in this panel
// (lib/model-table-columns.ts, components/PricingHealthTable.vue, lib/
// target-columns.ts) — a right-aligned, sortable count column rendered via
// CompactNumber, a right-aligned cost column rendered via formatCost, and
// an "undefined reads as a placeholder" optional numeric column (p50/p95,
// context window). Each factory is generic over the row type `T`: the
// duplication these replace was never about the ROW SHAPE (every caller
// already has its own row interface), only about the identical id/header/
// accessor/cell wiring built on top of it.
import type { ColumnDef } from '@tanstack/vue-table'
import { h } from 'vue'
import CompactNumber from '@/components/CompactNumber.vue'
import { formatCost } from '@/lib/format'
/** EMPTY_CELL is the ONE "no value to show" placeholder every table cell in this panel renders instead of a blank — model-table-columns.ts, PricingHealthTable.vue, usage-columns.ts, and events-columns.ts each used to redeclare the same '—' literal. */
export const EMPTY_CELL = '—'
/** compactColumn builds one right-aligned, sortable numeric column rendered via CompactNumber (SI-style, exact value on title/aria-label) — the plain case with no storeDown masking or budget-bar overlay (usage-columns.ts's own richer numericColumn keeps those, see its own doc comment for why it is not built on this one). */
export function compactColumn<T>(id: string, header: string, read: (row: T) => number): ColumnDef<T, unknown> {
return {
id,
header,
meta: { align: 'right' },
accessorFn: (r) => read(r),
cell: ({ row }) => h(CompactNumber, { value: read(row.original) }),
}
}
/** costColumn builds one right-aligned, sortable micro-USD column rendered via formatCost — the plain case (a caller with its own special-case cell, e.g. model-table-columns.ts's free-priced-model badge, keeps its own hand-written column instead). */
export function costColumn<T>(id: string, header: string, read: (row: T) => number): ColumnDef<T, unknown> {
return {
id,
header,
meta: { align: 'right' },
accessorFn: (r) => read(r),
cell: ({ row }) => formatCost(read(row.original)),
}
}
/**
* optionalColumn builds one right-aligned, sortable column for a value
* that may be undefined (no observations yet, a feature switched off) —
* EMPTY_CELL for the cell, `sortSentinel` (default 0) for the accessor so
* an "undefined" row still sorts somewhere deterministic (p50/p95 pass -1,
* so "no data" sorts below a genuine 0ms reading rather than tying with
* it; context window's own 0 default is fine as-is, nothing sorts below a
* real context size of 0).
*/
export function optionalColumn<T>(
id: string,
header: string,
read: (row: T) => number | undefined,
format: (n: number) => string,
opts?: { sortSentinel?: number },
): ColumnDef<T, unknown> {
const sentinel = opts?.sortSentinel ?? 0
return {
id,
header,
meta: { align: 'right' },
accessorFn: (r) => read(r) ?? sentinel,
cell: ({ row }) => {
const value = read(row.original)
return h('span', { class: 'tabular-nums' }, value !== undefined ? format(value) : EMPTY_CELL)
},
}
}
/** UsageMetricRow is the field set usageMetricColumns reads — every row shape in this panel that carries per-model/per-scope requests/tokens/cost (ModelCatalogRow, PricingHealthRow) already has exactly these four fields under these exact names. */
export interface UsageMetricRow {
requests: number
tokensIn: number
tokensOut: number
costMicroUsd: number
}
/** usageMetricColumns builds the requests/tokensIn/tokensOut/cost column set every usage-ranked model table in this panel repeats (reuse-audit.md F9) — a caller with its own special-case cost cell (e.g. a free-priced-model badge) omits 'cost' here and appends its own instead. */
export function usageMetricColumns<T extends UsageMetricRow>(): ColumnDef<T, unknown>[] {
return [
compactColumn<T>('requests', 'Requests', (r) => r.requests),
compactColumn<T>('tokensIn', 'Tokens in', (r) => r.tokensIn),
compactColumn<T>('tokensOut', 'Tokens out', (r) => r.tokensOut),
costColumn<T>('cost', 'Cost', (r) => r.costMicroUsd),
]
}
+8 -12
View File
@@ -2,26 +2,22 @@ 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 { EMPTY_CELL } from '@/lib/columns'
import { eventKindVariant, kindLabel } 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
}
import type { AdminEventView } from '@/types/api'
/** 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 '—'
return EMPTY_CELL
}
/** 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(' → ') : '—'
return parts.length ? parts.join(' → ') : EMPTY_CELL
}
/**
@@ -63,7 +59,7 @@ export function eventsColumns(): ColumnDef<AdminEventView, unknown>[] {
accessorFn: (e) => e.user ?? e.group ?? '',
cell: ({ row }) => {
const text = scopeText(row.original)
return h('span', { class: text === '—' ? 'text-muted-foreground' : undefined }, text)
return h('span', { class: text === EMPTY_CELL ? 'text-muted-foreground' : undefined }, text)
},
},
{
@@ -73,7 +69,7 @@ export function eventsColumns(): ColumnDef<AdminEventView, unknown>[] {
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)
return h('span', { class: text === EMPTY_CELL ? 'text-muted-foreground' : 'font-mono text-xs' }, text)
},
},
{
@@ -88,7 +84,7 @@ export function eventsColumns(): ColumnDef<AdminEventView, unknown>[] {
meta: { align: 'right' },
accessorFn: (e) => e.status ?? 0,
cell: ({ row }) =>
h('span', { class: 'tabular-nums' }, row.original.status !== undefined ? String(row.original.status) : '—'),
h('span', { class: 'tabular-nums' }, row.original.status !== undefined ? String(row.original.status) : EMPTY_CELL),
},
{
id: 'message',
+13 -1
View File
@@ -1,6 +1,6 @@
import { describe, expect, it } from 'vitest'
import { eventKindVariant, EVENT_KIND_LABEL, EVENT_KINDS, filterEvents } from './events-filter'
import { eventKindVariant, EVENT_KIND_LABEL, EVENT_KINDS, filterEvents, kindLabel } from './events-filter'
import type { AdminEventView } from '@/types/api'
function event(overrides: Partial<AdminEventView> = {}): AdminEventView {
@@ -40,6 +40,18 @@ describe('EVENT_KINDS', () => {
})
})
describe('kindLabel', () => {
it('renders the known display label for every AdminEventKind', () => {
for (const kind of EVENT_KINDS) {
expect(kindLabel(kind)).toBe(EVENT_KIND_LABEL[kind])
}
})
it('falls back to the raw kind string for a kind this build does not recognize', () => {
expect(kindLabel('some_future_kind')).toBe('some_future_kind')
})
})
describe('eventKindVariant', () => {
it.each([
['rate_limit', 'destructive'],
+12
View File
@@ -26,6 +26,18 @@ export const EVENT_KIND_LABEL: Record<AdminEventKind, string> = {
* there first).
*/
export const EVENT_KINDS = Object.keys(EVENT_KIND_LABEL) as AdminEventKind[]
/**
* kindLabel (reuse-audit.md F13) renders one event's kind as display text,
* falling back to the raw kind string for a kind EVENT_KIND_LABEL does not
* know (a server newer than this webui build) — same forward-compat
* convention AdminEventView.kind's own `| string` union documents. The ONE
* shared implementation: lib/events-columns.ts's Events-tab column and
* HomePage.vue's "Latest events" card used to each hand-roll an identical
* copy of this exact ternary.
*/
export function kindLabel(kind: string): string {
return EVENT_KIND_LABEL[kind as AdminEventKind] ?? kind
}
/**
* eventKindVariant maps a kind to the shadcn-vue Badge variant its row
+33 -1
View File
@@ -1,6 +1,6 @@
import { describe, expect, it } from 'vitest'
import { headroom, MIN_PROJECTION_FRACTION, monthProgress, projectMonthEnd } from './forecast'
import { headroom, MIN_PROJECTION_FRACTION, monthCostProjection, monthProgress, NOT_ENOUGH_DATA, projectMonthEnd } from './forecast'
describe('monthProgress', () => {
it('reads 0 at the first instant of a UTC month', () => {
@@ -105,3 +105,35 @@ describe('headroom', () => {
expect(headroom(5_000_000, 5)).toEqual({ limitMicros: 5_000_000, remainingMicros: 0, willExceed: false })
})
})
describe('monthCostProjection', () => {
const halfMonth = new Date('2026-04-16T00:00:00Z') // Apr 1 -> May 1 UTC, elapsed 15/30 = 0.5
it('returns micros: null, willExceed: false too early in the month to project (NOT_ENOUGH_DATA is the caller\'s own display text)', () => {
const tooEarly = new Date('2026-04-01T00:00:00Z')
expect(monthCostProjection({ costPerMonthMicroUsd: 1_000_000 }, tooEarly)).toEqual({ micros: null, willExceed: false })
})
it('projects month-end cost and reports willExceed false when no limit is configured', () => {
expect(monthCostProjection({ costPerMonthMicroUsd: 15_000_000 }, halfMonth)).toEqual({ micros: 30_000_000, willExceed: false })
})
it('reports willExceed false when the projection stays under the entry\'s own configured limit', () => {
expect(monthCostProjection({ costPerMonthMicroUsd: 15_000_000, limits: { costPerMonthUSD: 100 } }, halfMonth)).toEqual({
micros: 30_000_000,
willExceed: false,
})
})
it('reports willExceed true when the projection crosses the entry\'s own configured limit', () => {
expect(monthCostProjection({ costPerMonthMicroUsd: 15_000_000, limits: { costPerMonthUSD: 10 } }, halfMonth)).toEqual({
micros: 30_000_000,
willExceed: true,
})
})
})
describe('NOT_ENOUGH_DATA', () => {
it('is a non-empty display string', () => {
expect(NOT_ENOUGH_DATA).toBeTruthy()
})
})
+39 -6
View File
@@ -15,11 +15,14 @@
// 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'
// usdToMicros (P11 review fix, DRY; reuse-audit.md F8): imported from
// lib/usage-bars.ts, which re-exports lib/format.ts's own one shared
// definition — this module used to declare its own identical MICROS_PER_USD
// copy, one of three (usage-bars.ts, usage-columns.ts) an earlier review
// flagged, then hand-rolled `Math.round(x * MICROS_PER_USD)` here even
// after that consolidation (F8's own gap) instead of calling the shared
// conversion function.
import { usdToMicros } from '@/lib/usage-bars'
/**
* MIN_PROJECTION_FRACTION is the smallest elapsed-month fraction
@@ -100,7 +103,37 @@ export function headroom(projectedMicros: number | null, limitUsd: number | unde
if (projectedMicros === null || limitUsd === undefined || limitUsd <= 0) {
return { limitMicros: null, remainingMicros: null, willExceed: false }
}
const limitMicros = Math.round(limitUsd * MICROS_PER_USD)
const limitMicros = usdToMicros(limitUsd)
const remainingMicros = limitMicros - projectedMicros
return { limitMicros, remainingMicros, willExceed: remainingMicros < 0 }
}
/** NOT_ENOUGH_DATA (reuse-audit.md F7) is the ONE "too early in the month to project" message — projectMonthEnd's own null case, shown wherever a projected/run-out figure has nothing to report yet. */
export const NOT_ENOUGH_DATA = 'not enough data yet'
/** WILL_EXCEED_TITLE (reuse-audit.md F7) is the ONE tooltip text for a projection that crosses its own configured limit (headroom's own willExceed). */
export const WILL_EXCEED_TITLE = 'Projected to exceed the configured cost/month limit'
/** MonthCostProjection is monthCostProjection's own result: the projected month-end micro-USD total (null below MIN_PROJECTION_FRACTION, projectMonthEnd's own convention), and whether it crosses the entry's own configured cost/month limit. */
export interface MonthCostProjection {
micros: number | null
willExceed: boolean
}
/**
* monthCostProjection projects one usage entry's month-end cost
* (projectMonthEnd) and checks it against that SAME entry's own configured
* cost/month limit (headroom) in one call — the ONE per-row month-end
* projection every table/detail-grid/CSV export in this panel used to
* re-derive separately (ConsumerDirectory.vue's groupCostProjection,
* lib/usage-columns.ts's costMonthProjColumn, lib/usage-csv.ts's own CSV
* row), each pairing projectMonthEnd + headroom by hand. Takes only the
* two fields it actually reads, not a full AdminUsageEntryView, so a
* caller building one from a narrower shape (or a test) never needs to
* fill in fields this has no use for.
*/
export function monthCostProjection(entry: { costPerMonthMicroUsd: number; limits?: { costPerMonthUSD?: number } }, now: Date): MonthCostProjection {
const projected = projectMonthEnd(entry.costPerMonthMicroUsd, monthProgress(now))
if (projected === null) return { micros: null, willExceed: false }
return { micros: projected, willExceed: headroom(projected, entry.limits?.costPerMonthUSD).willExceed }
}
+37 -1
View File
@@ -18,9 +18,45 @@ export function routableModelId(providerName: string, modelId: string): string {
return `${providerName}/${modelId}`
}
/**
* MICROS_PER_USD converts a plain USD amount (e.g. a LimitsConfig cost
* limit, `costPerDayUSD: 5`) into the micro-USD unit AdminUsageEntryView's
* own cost fields already use — matching metrics.go's usdToMicros(round)
* convention (ground truth, dashboard-plan.md section 0).
*
* reuse-audit.md F8: the ONE definition (moved here from lib/usage-bars.ts,
* which re-exports it for every existing import site) — usdToMicros/
* microsToUsd below are the ONE place a USD<->micro-USD conversion happens,
* replacing the hand-rolled `Math.round(usd * 1_000_000)` / `micros /
* 1_000_000` copies that used to bypass this constant.
*/
export const MICROS_PER_USD = 1_000_000
/** usdToMicros converts a plain USD amount to micro-USD, rounded to the nearest whole micro-USD (metrics.go's own usdToMicros(round) convention). */
export function usdToMicros(usd: number): number {
return Math.round(usd * MICROS_PER_USD)
}
/** microsToUsd converts a micro-USD integer back to a plain USD float — the exact inverse of usdToMicros, modulo usdToMicros' own rounding. */
export function microsToUsd(micros: number): number {
return micros / MICROS_PER_USD
}
/** formatCost renders a micro-USD integer (adminUsageEntryView's convention) as "$1.2345". */
export function formatCost(micros: number): string {
return `$${(micros / 1_000_000).toFixed(4)}`
return `$${microsToUsd(micros).toFixed(4)}`
}
/**
* formatShortDate renders a Date as a compact "Oct 15" label (month/day
* only, no time-of-day) — reuse-audit.md F13: the ONE run-out-date format,
* replacing BurnDownChart.vue's and UserDetail.vue's two disagreeing
* formats (a short date vs. a full formatTimestamp). A PROJECTED date (a
* run-out estimate) should never imply false time-of-day precision, so
* this intentionally omits it.
*/
export function formatShortDate(d: Date): string {
return d.toLocaleDateString(undefined, { month: 'short', day: 'numeric' })
}
/** formatExactInt renders a raw integer count with thousands separators ("1,234,567") — the precise value CompactNumber.vue and the usage chart's tooltip carry alongside a compact rendering, never lost. */
+2 -2
View File
@@ -9,7 +9,7 @@
// boundaries for the same two underlying concepts (a used/limit ratio,
// a success rate) the rest of this panel already colors consistently.
import { providerRateStatus, providerSuccessRate } from '@/lib/provider-rate'
import { MICROS_PER_USD, ratioTier } from '@/lib/usage-bars'
import { ratioTier, usdToMicros } from '@/lib/usage-bars'
import type { AdminGroupView, AdminProviderView } from '@/types/api'
/** KpiTier is KpiTile.vue's own three-color scale — 'ok' (normal), 'warn' (approaching a limit / minor degradation), 'critical' (over a limit / severe degradation). */
@@ -27,7 +27,7 @@ export function sumGroupBudgetMicros(groups: AdminGroupView[]): number {
let total = 0
for (const group of groups) {
const limitUsd = group.limits?.costPerMonthUSD
if (limitUsd && limitUsd > 0) total += Math.round(limitUsd * MICROS_PER_USD)
if (limitUsd && limitUsd > 0) total += usdToMicros(limitUsd)
}
return total
}
+14 -51
View File
@@ -1,10 +1,10 @@
import type { ColumnDef } from '@tanstack/vue-table'
import { h } from 'vue'
import CompactNumber from '@/components/CompactNumber.vue'
import EntityLink from '@/components/EntityLink.vue'
import ModelChip from '@/components/ModelChip.vue'
import { Badge } from '@/components/ui/badge'
import { EMPTY_CELL, optionalColumn, usageMetricColumns } from '@/lib/columns'
import { formatContextWindow, formatCost, formatLatencyMs, formatModelCostHover } from '@/lib/format'
import { formatErrorRatePercent, providerErrorRate } from '@/lib/provider-rate'
import type { AdminCatalogResponse, AdminPerfResponse, AdminUsageModelsResponse, PriceSource } from '@/types/api'
@@ -117,7 +117,7 @@ function priceCell(row: ModelCatalogRow) {
// captured into its own variable first.
const source = row.priceSource as Exclude<PriceSource, 'unpriced' | 'free'>
return h('span', { class: 'inline-flex flex-wrap items-center gap-1.5' }, [
h('span', {}, known ? formatModelCostHover(row.inputPerMTokUsd as number, row.outputPerMTokUsd as number) : '—'),
h('span', {}, known ? formatModelCostHover(row.inputPerMTokUsd as number, row.outputPerMTokUsd as number) : EMPTY_CELL),
h(Badge, { as: 'span', variant: 'outline', class: 'font-normal' }, () => PRICE_SOURCE_LABEL[source]),
])
}
@@ -162,18 +162,7 @@ export function modelCatalogColumns(): ColumnDef<ModelCatalogRow, unknown>[] {
h(EntityLink, { label: row.original.id, kind: 'model', id: row.original.id }),
]),
},
{
id: 'context',
header: 'Context',
meta: { align: 'right' },
accessorFn: (r) => r.contextTokens ?? 0,
cell: ({ row }) =>
h(
'span',
{ class: 'tabular-nums' },
row.original.contextTokens !== undefined ? formatContextWindow(row.original.contextTokens) : '—',
),
},
optionalColumn<ModelCatalogRow>('context', 'Context', (r) => r.contextTokens, formatContextWindow),
{
id: 'price',
header: 'Price',
@@ -191,7 +180,7 @@ export function modelCatalogColumns(): ColumnDef<ModelCatalogRow, unknown>[] {
{ as: 'span', variant: 'secondary', class: 'font-normal', title: "':free' suffix model — display-only, billing follows the Price column's own source" },
() => 'free',
)
: h('span', { class: 'text-muted-foreground' }, '—'),
: h('span', { class: 'text-muted-foreground' }, EMPTY_CELL),
},
{
id: 'aliases',
@@ -200,7 +189,7 @@ export function modelCatalogColumns(): ColumnDef<ModelCatalogRow, unknown>[] {
accessorFn: (r) => r.aliases.join(', '),
cell: ({ row }) => {
const aliases = row.original.aliases
if (aliases.length === 0) return h('span', { class: 'text-muted-foreground' }, '—')
if (aliases.length === 0) return h('span', { class: 'text-muted-foreground' }, EMPTY_CELL)
return h(
'div',
{ class: 'flex flex-wrap gap-1' },
@@ -208,27 +197,13 @@ export function modelCatalogColumns(): ColumnDef<ModelCatalogRow, unknown>[] {
)
},
},
{
id: 'requests',
header: 'Requests',
meta: { align: 'right' },
accessorFn: (r) => r.requests,
cell: ({ row }) => h(CompactNumber, { value: row.original.requests }),
},
{
id: 'tokensIn',
header: 'Tokens in',
meta: { align: 'right' },
accessorFn: (r) => r.tokensIn,
cell: ({ row }) => h(CompactNumber, { value: row.original.tokensIn }),
},
{
id: 'tokensOut',
header: 'Tokens out',
meta: { align: 'right' },
accessorFn: (r) => r.tokensOut,
cell: ({ row }) => h(CompactNumber, { value: row.original.tokensOut }),
},
// reuse-audit.md F9: requests/tokensIn/tokensOut share the shared
// usageMetricColumns factory; 'cost' stays hand-written below since
// this table's cost cell has an extra free-priced-model badge case
// usageMetricColumns' own plain costColumn does not (and should not —
// PricingHealthTable.vue's identical-shaped cost column has no such
// badge) carry.
...usageMetricColumns<ModelCatalogRow>().filter((c) => c.id !== 'cost'),
{
id: 'cost',
header: 'Cost',
@@ -242,20 +217,8 @@ export function modelCatalogColumns(): ColumnDef<ModelCatalogRow, unknown>[] {
return formatCost(r.costMicroUsd)
},
},
{
id: 'p50',
header: 'p50',
meta: { align: 'right' },
accessorFn: (r) => r.p50Ms ?? -1,
cell: ({ row }) => h('span', { class: 'tabular-nums' }, row.original.p50Ms !== undefined ? formatLatencyMs(row.original.p50Ms) : '—'),
},
{
id: 'p95',
header: 'p95',
meta: { align: 'right' },
accessorFn: (r) => r.p95Ms ?? -1,
cell: ({ row }) => h('span', { class: 'tabular-nums' }, row.original.p95Ms !== undefined ? formatLatencyMs(row.original.p95Ms) : '—'),
},
optionalColumn<ModelCatalogRow>('p50', 'p50', (r) => r.p50Ms, formatLatencyMs, { sortSentinel: -1 }),
optionalColumn<ModelCatalogRow>('p95', 'p95', (r) => r.p95Ms, formatLatencyMs, { sortSentinel: -1 }),
{
id: 'errorRate',
header: 'Error rate',
+4
View File
@@ -177,4 +177,8 @@ describe('formatErrorRatePercent', () => {
it('does not clamp a rate above 1 (should not occur with real counters, but is not silently capped)', () => {
expect(formatErrorRatePercent(1.8)).toBe('180%')
})
it('renders null as the caller-supplied nullLabel instead of the "no data" default', () => {
expect(formatErrorRatePercent(null, 'no traffic')).toBe('no traffic')
})
})
+7 -4
View File
@@ -118,13 +118,16 @@ export function providerErrorRate(attempts: number, failures: number): number |
* formatRatePercent above (a SUCCESS rate must never overstate itself as
* 100%; an ERROR rate must never round down to a misleadingly clean 0%).
* `null` (no traffic to compute a rate from — providerErrorRate's own
* "nothing to report" case) renders as "no data", never "0%". Not clamped
* to 100%: a rate above 1 should not occur with real counters, but this
* "nothing to report" case) renders as `nullLabel` (default "no data"),
* never "0%" — reuse-audit.md F13: the `nullLabel` param lets HomePage.vue's
* fleet-wide reading pass "no traffic" instead of hand-rolling an identical
* copy of this function with a different null wording. Not clamped to
* 100%: a rate above 1 should not occur with real counters, but this
* function reports whatever the caller computed rather than silently
* capping it (lib/series.ts's own ratioSeries makes the identical choice).
*/
export function formatErrorRatePercent(rate: number | null): string {
if (rate === null) return 'no data'
export function formatErrorRatePercent(rate: number | null, nullLabel = 'no data'): string {
if (rate === null) return nullLabel
if (rate <= 0) return '0%'
return `${Math.ceil(rate * 100)}%`
}
+114
View File
@@ -0,0 +1,114 @@
import { describe, expect, it } from 'vitest'
import { drilldownLevel, makeScope, parseScope, scopeBudgetLimits, scopeId } from './scope'
import type { AdminGroupView, AdminUsageEntryView } from '@/types/api'
function group(overrides: Partial<AdminGroupView> = {}): AdminGroupView {
return { name: 'eng', memberCount: 1, ...overrides }
}
function user(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('parseScope', () => {
it.each([
['group:eng', { kind: 'group', id: 'eng' }],
['user:alice', { kind: 'user', id: 'alice' }],
['model:gpt-4', { kind: 'model', id: 'gpt-4' }],
['provider:openai', { kind: 'provider', id: 'openai' }],
] as const)('splits %s into its kind and id', (input, expected) => {
expect(parseScope(input)).toEqual(expected)
})
it('returns null for a string with no recognized "kind:" prefix', () => {
expect(parseScope('all')).toBeNull()
expect(parseScope('total')).toBeNull()
expect(parseScope('')).toBeNull()
})
it('splits only on the FIRST colon, keeping the rest of the id intact', () => {
expect(parseScope('model:openai/gpt-4:latest')).toEqual({ kind: 'model', id: 'openai/gpt-4:latest' })
})
})
describe('scopeId', () => {
it('strips the "kind:" prefix', () => {
expect(scopeId('group:eng')).toBe('eng')
expect(scopeId('provider:openai')).toBe('openai')
})
it('returns the string unchanged when it carries no recognized prefix', () => {
expect(scopeId('all')).toBe('all')
expect(scopeId('total')).toBe('total')
})
})
describe('makeScope', () => {
it('is parseScope\'s own inverse', () => {
expect(makeScope('group', 'eng')).toBe('group:eng')
expect(parseScope(makeScope('user', 'alice'))).toEqual({ kind: 'user', id: 'alice' })
})
})
describe('drilldownLevel', () => {
it('reads "" as the group level', () => {
expect(drilldownLevel('')).toEqual({ kind: 'group' })
})
it('reads "group:x" as the user level, naming the parent group', () => {
expect(drilldownLevel('group:eng')).toEqual({ kind: 'user', group: 'eng' })
})
it('reads "user:x" as the usermodel level, naming the user', () => {
expect(drilldownLevel('user:alice')).toEqual({ kind: 'usermodel', user: 'alice' })
})
it('reads any other non-empty string as the usermodel level verbatim (defensive fallback)', () => {
expect(drilldownLevel('alice')).toEqual({ kind: 'usermodel', user: 'alice' })
})
})
describe('scopeBudgetLimits', () => {
const groups = [group({ name: 'eng', limits: { costPerMonthUSD: 100 } }), group({ name: 'ops', limits: { costPerMonthUSD: 50 } })]
const users = [user({ id: 'alice', limits: { costPerMonthUSD: 10 } })]
it('sums every configured group\'s own budget for "all" (or any other unscoped root string)', () => {
expect(scopeBudgetLimits('all', groups, users)).toEqual({ costPerMonthUSD: 150 })
expect(scopeBudgetLimits('total', groups, users)).toEqual({ costPerMonthUSD: 150 })
})
it('returns undefined for the fleet-wide sum when no group has a budget configured', () => {
expect(scopeBudgetLimits('all', [group({ limits: undefined })], [])).toBeUndefined()
})
it('resolves a group scope to that one group\'s own limits', () => {
expect(scopeBudgetLimits('group:eng', groups, users)).toEqual({ costPerMonthUSD: 100 })
})
it('resolves a user scope to that one user\'s own limits', () => {
expect(scopeBudgetLimits('user:alice', groups, users)).toEqual({ costPerMonthUSD: 10 })
})
it('returns undefined for a group/user scope this fleet does not actually have', () => {
expect(scopeBudgetLimits('group:nope', groups, users)).toBeUndefined()
expect(scopeBudgetLimits('user:nope', groups, users)).toBeUndefined()
})
it('returns undefined for a provider scope (no single group/user limit applies)', () => {
expect(scopeBudgetLimits('provider:openai', groups, users)).toBeUndefined()
})
})
+91
View File
@@ -0,0 +1,91 @@
// reuse-audit.md F5: scope-string parsing (the `group:`/`user:`/`model:`/
// `provider:` prefix convention shared by stores/filters.ts's global scope
// filter, AttributionDrilldown's `drill` breadcrumb, and every per-series
// `scope` string an /admin/api/usage/* or /admin/api/events response
// carries) used to be reimplemented at every call site: a prefix strip
// three different ways, `drilldownLevel` duplicated verbatim, and a
// scope-to-budget lookup written out twice more. Kept pure and out of any
// component/store the same way lib/series.ts and lib/kpi.ts keep their own
// math out of theirs — vitest's node-environment config exercises every
// function here directly, no component mount required.
import { sumGroupBudgetMicros } from '@/lib/kpi'
import { microsToUsd } from '@/lib/usage-bars'
import type { AdminGroupView, AdminUsageEntryView, LimitsConfig } from '@/types/api'
/** ScopeKind is every prefix a "kind:id" scope string can carry across this panel — not every caller accepts every kind (a Spend burn-down scope never carries 'provider', a Reliability series scope never carries 'group'/'user'), but the prefix syntax itself is the same everywhere. */
export type ScopeKind = 'group' | 'user' | 'model' | 'provider'
export interface ParsedScope {
kind: ScopeKind
id: string
}
/**
* parseScope splits a "kind:id" scope string into its parts. Returns null
* for a string with no recognized "kind:" prefix — a caller's own root/
* unscoped sentinel ('all', 'total', or AttributionDrilldown's drill ''),
* which only that caller knows how to interpret, so this never guesses at
* one.
*/
export function parseScope(scope: string): ParsedScope | null {
for (const kind of ['group', 'user', 'model', 'provider'] as const) {
const prefix = `${kind}:`
if (scope.startsWith(prefix)) return { kind, id: scope.slice(prefix.length) }
}
return null
}
/** scopeId strips a scope string's own "kind:" prefix, or returns it unchanged when it carries none (the caller's own root sentinel, or an id that was never prefixed to begin with). */
export function scopeId(scope: string): string {
return parseScope(scope)?.id ?? scope
}
/** makeScope builds a "kind:id" scope string — parseScope's own inverse. */
export function makeScope(kind: ScopeKind, id: string): string {
return `${kind}:${id}`
}
/** DrilldownLevel is AttributionDrilldown's current GET /admin/api/usage/totals request shape, derived from its `drill` breadcrumb state. */
export type DrilldownLevel = { kind: 'group' } | { kind: 'user'; group: string } | { kind: 'usermodel'; user: string }
/**
* drilldownLevel derives AttributionDrilldown's current level from its
* `drill` breadcrumb state: '' is the top (every group listed), a
* `group:{name}` drill shows that group's own users, anything else is
* read as a `user:{name}` drill showing that user's own per-model
* breakdown — the ONE definition (moved from stores/spend.ts, which used
* to duplicate AttributionDrilldown.vue's own identical copy verbatim).
*/
export function drilldownLevel(drill: string): DrilldownLevel {
if (drill === '') return { kind: 'group' }
const parsed = parseScope(drill)
if (parsed?.kind === 'group') return { kind: 'user', group: parsed.id }
return { kind: 'usermodel', user: parsed?.kind === 'user' ? parsed.id : drill }
}
/**
* scopeBudgetLimits resolves the configured cost/month LimitsConfig for a
* `filters.scope`-shaped string (redesign-plan.md section 3.1:
* `scope=all|group:x|user:x|provider:x`) from the already-polled dashboard
* overview/usage data — no second network round trip. `'all'` (or any
* unscoped/root string parseScope does not recognize a `group:`/`user:`
* prefix on) resolves to the FLEET-WIDE sum of every configured group's
* own budget (Q2/DECISIONS: lib/kpi.ts's sumGroupBudgetMicros — the ONE
* fleet-budget definition, replacing SpendPage.vue's own inline recompute
* of the identical sum), `undefined` when that total is 0 ("no budget
* set", never a fabricated zero-dollar budget). `group:`/`user:` resolve
* to that one group's/user's own configured limits (undefined if the
* scope names a group/user this fleet does not actually have); a
* `provider:` scope (or any other unrecognized prefix) has no single
* group/user limit to report and also resolves to undefined.
*/
export function scopeBudgetLimits(scope: string, groups: AdminGroupView[], users: AdminUsageEntryView[]): LimitsConfig | undefined {
const parsed = parseScope(scope)
if (!parsed) {
const totalMicros = sumGroupBudgetMicros(groups)
return totalMicros > 0 ? { costPerMonthUSD: microsToUsd(totalMicros) } : undefined
}
if (parsed.kind === 'group') return groups.find((g) => g.name === parsed.id)?.limits
if (parsed.kind === 'user') return users.find((u) => u.id === parsed.id)?.limits
return undefined
}
+4 -1
View File
@@ -11,6 +11,8 @@
// keep their own math out of theirs — vitest's node-environment config
// exercises every function here directly, no component mount required.
import { parseScope } from '@/lib/scope'
/** One AdminSeriesResponse series entry: a scope id and its bucket-aligned points (types/api.ts: AdminSeriesResponse['series'][number]). */
export interface Series {
scope: string
@@ -86,7 +88,8 @@ export function other(totalPoints: number[], charted: Series[]): number[] {
export function sumByProvider(series: Series[]): Series[] {
const byProvider = new Map<string, number[]>()
for (const s of series) {
const id = s.scope.startsWith('model:') ? s.scope.slice('model:'.length) : s.scope
const parsed = parseScope(s.scope)
const id = parsed?.kind === 'model' ? parsed.id : s.scope
const slash = id.indexOf('/')
const provider = slash === -1 ? id : id.slice(0, slash)
const acc = byProvider.get(provider) ?? new Array<number>(s.points.length).fill(0)
+18
View File
@@ -10,6 +10,7 @@ import {
isValidNonNegativeInt,
isValidUserName,
modelMetaSnippet,
parseList,
pricingSnippet,
userGrantJsonFragment,
userJsonLine,
@@ -17,6 +18,23 @@ import {
validatePersonalGrant,
} from './snippets'
describe('parseList', () => {
it('splits on commas and trims whitespace', () => {
expect(parseList('anthropic, openai , gx10')).toEqual(['anthropic', 'openai', 'gx10'])
})
it('drops empty entries (blank field, trailing comma, doubled comma)', () => {
expect(parseList('')).toEqual([])
expect(parseList('anthropic,')).toEqual(['anthropic'])
expect(parseList('anthropic,,openai')).toEqual(['anthropic', 'openai'])
expect(parseList(' ')).toEqual([])
})
it('de-duplicates, keeping first-seen order', () => {
expect(parseList('anthropic, openai, anthropic')).toEqual(['anthropic', 'openai'])
})
})
// Golden strings below are checked against the REAL production shapes in
// home-cluster/kubernetes/namespaces/traefik/base/llmgateway/middleware.yaml
// and users-configmap.yaml (read this session) — structure and field order
+14
View File
@@ -28,6 +28,20 @@ export const NAME_PATTERN = /^[a-zA-Z0-9._-]+$/
export function isValidName(name: string): boolean {
return NAME_PATTERN.test(name)
}
/**
* parseList (reuse-audit.md F12) splits a comma-separated form field into
* trimmed, non-empty, de-duplicated entries — the ONE shared parser
* GrantForm.vue's and UserForm.vue's own two identical copies (providers/
* models/mcpServers/agents, extraGroups) each hand-rolled.
*/
export function parseList(raw: string): string[] {
const seen = new Set<string>()
for (const part of raw.split(',')) {
const trimmed = part.trim()
if (trimmed) seen.add(trimmed)
}
return Array.from(seen)
}
/**
* isValidUserName mirrors auth.go's own buildEntry check (~955-975,
+5 -33
View File
@@ -1,33 +1,11 @@
import type { ColumnDef } from '@tanstack/vue-table'
import { h } from 'vue'
import CompactNumber from '@/components/CompactNumber.vue'
import { Badge } from '@/components/ui/badge'
import { compactColumn } from '@/lib/columns'
import { formatElapsedAgo, formatLatencyMs } from '@/lib/format'
import type { AdminTargetHealthView, AdminTargetsResponse, AdminTargetView, AdminTotalsResponse } from '@/types/api'
/**
* numericColumn builds one right-aligned, sortable request-counter
* column, rendered via CompactNumber (SI-style, exact value on title/
* aria-label — usage-columns.ts's own numericColumn applies the
* identical treatment). Mirrors usage-columns.ts's own helper of the
* same name and purpose, but without that one's storeDown '?' masking: a
* target's counters carry no storeDown flag (limiter.targetUsage,
* limits.go — a target scope has no limit to protect from a
* misleadingly-confident zero, unlike a limited user/group scope), so
* there is nothing to mask here. accessorFn (and therefore sorting)
* always reads the true raw number.
*/
function numericColumn(id: string, header: string, read: (t: AdminTargetView) => number): ColumnDef<AdminTargetView, unknown> {
return {
id,
header,
accessorFn: read,
meta: { align: 'right' },
cell: ({ row }) => h(CompactNumber, { value: read(row.original) }),
}
}
// --- target health (feat/target-health) ---
//
// Mirrors ProviderHealthPanel.vue's own healthBadgeVariant/healthBadgeLabel
@@ -181,9 +159,9 @@ export function targetColumns(onSelect?: (target: AdminTargetView) => void): Col
)
},
},
numericColumn('reqMin', 'req/min', (t) => t.counters.requestsPerMinute),
numericColumn('reqDay', 'req/day', (t) => t.counters.requestsPerDay),
numericColumn('reqMonth', 'req/month', (t) => t.counters.requestsPerMonth),
compactColumn<AdminTargetView>('reqMin', 'req/min', (t) => t.counters.requestsPerMinute),
compactColumn<AdminTargetView>('reqDay', 'req/day', (t) => t.counters.requestsPerDay),
compactColumn<AdminTargetView>('reqMonth', 'req/month', (t) => t.counters.requestsPerMonth),
]
}
@@ -336,12 +314,6 @@ export function targetCallerColumns(showTarget: boolean): ColumnDef<TargetCaller
return h('span', { class: 'font-medium' }, caller || row.original.id)
},
})
columns.push({
id: 'requests',
header: 'Requests',
meta: { align: 'right' },
accessorFn: (row) => row.values.req ?? 0,
cell: ({ row }) => h(CompactNumber, { value: row.original.values.req ?? 0 }),
})
columns.push(compactColumn<TargetCallerRow>('requests', 'Requests', (row) => row.values.req ?? 0))
return columns
}
+12 -1
View File
@@ -1,6 +1,6 @@
import { describe, expect, it } from 'vitest'
import { BAR_AMBER_THRESHOLD, BAR_RED_THRESHOLD, budgetRatios, ratioTier } from './usage-bars'
import { BAR_AMBER_THRESHOLD, BAR_RED_THRESHOLD, budgetRatios, budgetValueText, ratioTier } from './usage-bars'
import type { AdminUsageEntryView } from '@/types/api'
function testEntry(overrides: Partial<AdminUsageEntryView> = {}): AdminUsageEntryView {
@@ -121,6 +121,17 @@ describe('budgetRatios', () => {
})
})
describe('budgetValueText', () => {
it('formats a cost-kind ratio (id starting "cost") through formatCost, both sides', () => {
expect(budgetValueText({ id: 'costMonth', used: 1_500_000, limit: 5_000_000, ratio: 0.3 })).toBe('$1.5000 / $5.0000')
})
it('formats every other kind through formatExactInt, both sides', () => {
expect(budgetValueText({ id: 'reqMin', used: 42, limit: 100, ratio: 0.42 })).toBe('42 / 100')
expect(budgetValueText({ id: 'tokDay', used: 1_500, limit: 2_000, ratio: 0.75 })).toBe('1,500 / 2,000')
})
})
// estimatedRunOutDate was removed (P2 item 14 — it disagreed with
// lib/burndown.ts's runOutDate on an already-exceeded budget and on day 1
// of the month; UserDetail.vue and BurnDownChart.vue now both call the
+30 -14
View File
@@ -38,19 +38,21 @@ export interface BudgetRatio {
}
/**
* 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).
* usdToMicros / microsToUsd (reuse-audit.md F8): moved to lib/format.ts
* (formatCost's own home — an "odd home" here, the audit's words, for
* conversions every cost-limit needs), re-exported here so every existing
* `from '@/lib/usage-bars'` import site (lib/forecast.ts, lib/kpi.ts, lib/
* usage-csv.ts, lib/scope.ts, stores/spend.ts, pages/HomePage.vue,
* components/UserDetail.vue) keeps working unchanged. P11 review fix
* (DRY) history: the underlying MICROS_PER_USD constant used to be
* declared independently three times (here, lib/forecast.ts, lib/usage-
* columns.ts) before the first consolidation onto this file; F8 is the
* second consolidation, onto lib/format.ts. MICROS_PER_USD itself is not
* re-exported here — nothing imports it from this module, only from lib/
* format.ts directly.
*/
export const MICROS_PER_USD = 1_000_000
export { usdToMicros, microsToUsd } from '@/lib/format'
import { formatCost, formatExactInt, usdToMicros } from '@/lib/format'
/**
* BUDGET_RATIO_LABEL (P10) names each BudgetRatio id in accurate,
@@ -122,11 +124,11 @@ export function budgetRatios(entry: AdminUsageEntryView): BudgetRatio[] {
// 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)
const limitMicros = usdToMicros(limits.costPerDayUSD)
if (limitMicros > 0) out.push(ratioOf('costDay', entry.costPerDayMicroUsd, limitMicros))
}
if (limits.costPerMonthUSD) {
const limitMicros = Math.round(limits.costPerMonthUSD * MICROS_PER_USD)
const limitMicros = usdToMicros(limits.costPerMonthUSD)
if (limitMicros > 0) out.push(ratioOf('costMonth', entry.costPerMonthMicroUsd, limitMicros))
}
return out
@@ -137,3 +139,17 @@ export function budgetRatios(entry: AdminUsageEntryView): BudgetRatio[] {
// ONE run-out projection in this codebase — it used to disagree with this
// one on an already-exceeded budget (null here vs `now` there) and on day
// 1 of the month (clamped/overstated here vs null there).
/**
* budgetValueText (reuse-audit.md F7) renders one BudgetRatio's "used /
* limit" detail text (UsageBar's aria-valuetext/title) — a cost-kind ratio
* (id starting "cost") formats both sides through formatCost (dollars),
* every other kind (requests, tokens) through formatExactInt (a plain
* thousands-grouped integer) — the ONE definition, replacing
* ConsumerDirectory.vue's and UserDetail.vue's own two copies (which had
* drifted onto two different signatures for the identical logic).
*/
export function budgetValueText(ratio: BudgetRatio): string {
if (ratio.id.startsWith('cost')) return `${formatCost(ratio.used)} / ${formatCost(ratio.limit)}`
return `${formatExactInt(ratio.used)} / ${formatExactInt(ratio.limit)}`
}
+17 -16
View File
@@ -4,7 +4,8 @@ import { h } from 'vue'
import CompactNumber from '@/components/CompactNumber.vue'
import EntityLink from '@/components/EntityLink.vue'
import UsageBar from '@/components/UsageBar.vue'
import { headroom, monthProgress, projectMonthEnd } from '@/lib/forecast'
import { EMPTY_CELL } from '@/lib/columns'
import { monthCostProjection, NOT_ENOUGH_DATA, WILL_EXCEED_TITLE } from '@/lib/forecast'
import { formatCost, formatExactInt, formatLimits } from '@/lib/format'
import { formatLastSeen } from '@/lib/last-seen'
import { BUDGET_RATIO_LABEL, budgetRatios } from '@/lib/usage-bars'
@@ -115,17 +116,19 @@ function rejDayColumn(): ColumnDef<AdminUsageEntryView, unknown> {
* 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.
* to blow its own cost/month limit, not just the fleet-wide total. Reuses
* lib/forecast.ts's own monthCostProjection (F7's one projection+headroom
* pairing) rather than calling projectMonthEnd/headroom separately here —
* the same helper ConsumerDirectory.vue's groupCostProjection and lib/
* usage-csv.ts's own CSV row call.
*
* `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.
* pin it instead.
*/
function costMonthProjColumn(elapsedFraction: number): ColumnDef<AdminUsageEntryView, unknown> {
function costMonthProjColumn(now: Date): ColumnDef<AdminUsageEntryView, unknown> {
return {
id: 'costMonthProj',
header: 'cost/month (proj.)',
@@ -133,19 +136,18 @@ function costMonthProjColumn(elapsedFraction: number): ColumnDef<AdminUsageEntry
// 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,
accessorFn: (entry) => monthCostProjection(entry, now).micros ?? 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)
const projection = monthCostProjection(row.original, now)
if (projection.micros === null) return h('span', { class: 'text-muted-foreground' }, NOT_ENOUGH_DATA)
return h(
'span',
{
class: result.willExceed ? 'text-destructive' : undefined,
title: result.willExceed ? 'Projected to exceed the configured cost/month limit' : undefined,
class: projection.willExceed ? 'text-destructive' : undefined,
title: projection.willExceed ? WILL_EXCEED_TITLE : undefined,
},
formatCost(projected),
formatCost(projection.micros),
)
},
}
@@ -170,7 +172,7 @@ function sourceColumn(sourceValue: (entry: AdminUsageEntryView) => string | unde
id: 'source',
header: 'Source',
accessorFn: (entry) => sourceValue(entry) ?? '',
cell: ({ row }) => h('span', { class: 'text-muted-foreground' }, sourceValue(row.original) ?? '—'),
cell: ({ row }) => h('span', { class: 'text-muted-foreground' }, sourceValue(row.original) ?? EMPTY_CELL),
}
}
@@ -217,7 +219,6 @@ export function usageColumns(
now: Date = new Date(),
sourceValue?: (entry: AdminUsageEntryView) => string | undefined,
): ColumnDef<AdminUsageEntryView, unknown>[] {
const elapsedFraction = monthProgress(now)
const columns: ColumnDef<AdminUsageEntryView, unknown>[] = [
{
id: 'id',
@@ -266,7 +267,7 @@ export function usageColumns(
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),
costMonthProjColumn(now),
lastSeenColumn(now),
)
return columns
+10 -7
View File
@@ -4,9 +4,9 @@
// formula-injection neutralisation) 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 { monthCostProjection, NOT_ENOUGH_DATA } from '@/lib/forecast'
import { formatLimits } from '@/lib/format'
import { MICROS_PER_USD } from '@/lib/usage-bars'
import { microsToUsd } from '@/lib/usage-bars'
import type { AdminUsageEntryView } from '@/types/api'
/** UsageCsvKind picks the second column's label — "Group" for a Users export (each row's own group membership), "Members" for a Groups export (each row's member count) — mirroring usage-columns.ts's own usageColumns(idLabel, secondaryColumnLabel, ...) call sites in UsageTable.vue/ConsumerDirectory.vue exactly. */
@@ -30,7 +30,7 @@ const USAGE_CSV_SECONDARY_LABEL: Record<UsageCsvKind, string> = {
* 1 micro-USD = $0.000001.
*/
function csvCostUsd(micros: number): string {
return (micros / MICROS_PER_USD).toFixed(6)
return microsToUsd(micros).toFixed(6)
}
/**
@@ -63,7 +63,11 @@ function csvCostUsd(micros: number): string {
* `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.
* follow. The projected month-end cost column reuses lib/forecast.ts's
* own monthCostProjection (the same helper costMonthProjColumn and
* ConsumerDirectory.vue's groupCostProjection call) rather than pairing
* projectMonthEnd by hand — this export only reads its `.micros`, never
* `.willExceed` (a CSV cell has no styling to apply).
*/
export function usageCsv(
entries: AdminUsageEntryView[],
@@ -71,7 +75,6 @@ export function usageCsv(
secondaryValue: (entry: AdminUsageEntryView) => string,
now: Date = new Date(),
): string {
const elapsedFraction = monthProgress(now)
const headers = [
'Name',
USAGE_CSV_SECONDARY_LABEL[kind],
@@ -90,7 +93,7 @@ export function usageCsv(
]
const rows = entries.map((entry) => {
const masked = entry.storeDown
const projected = masked ? null : projectMonthEnd(entry.costPerMonthMicroUsd, elapsedFraction)
const projected = masked ? null : monthCostProjection(entry, now).micros
return [
entry.id,
secondaryValue(entry),
@@ -104,7 +107,7 @@ export function usageCsv(
masked ? '?' : entry.tokensOutPerMonth,
masked ? '?' : csvCostUsd(entry.costPerDayMicroUsd),
masked ? '?' : csvCostUsd(entry.costPerMonthMicroUsd),
masked ? '?' : projected === null ? 'not enough data yet' : csvCostUsd(projected),
masked ? '?' : projected === null ? NOT_ENOUGH_DATA : csvCostUsd(projected),
// Never masked — lastSeen is its own SET-based counter family
// (redesign-plan.md section 1.2), independent of the INCRBY usage
// counters `storeDown` actually guards (see usage-columns.ts's
+16 -28
View File
@@ -3,18 +3,18 @@ import { computed, onMounted, onUnmounted, reactive, ref, watch } from 'vue'
import ErrorState from '@/components/ErrorState.vue'
import KpiTile from '@/components/KpiTile.vue'
import LoadStateView from '@/components/LoadStateView.vue'
import SkeletonKpiTile from '@/components/SkeletonKpiTile.vue'
import SkeletonList from '@/components/SkeletonList.vue'
import TopList from '@/components/TopList.vue'
import type { TopListItem } from '@/components/TopList.vue'
import TryLongerRangeButton from '@/components/TryLongerRangeButton.vue'
import { Badge } from '@/components/ui/badge'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs'
import { AdminApiError, adminFetch } from '@/lib/api'
import { EVENT_KIND_LABEL, eventKindVariant } from '@/lib/events-filter'
import { AdminApiError, adminFetch, messageOf } from '@/lib/api'
import { eventKindVariant, kindLabel } from '@/lib/events-filter'
import { formatCompactCount, formatCost, formatTimestamp } from '@/lib/format'
import { headroom, monthProgress, projectMonthEnd } from '@/lib/forecast'
import { headroom, monthProgress, NOT_ENOUGH_DATA, projectMonthEnd } from '@/lib/forecast'
import {
budgetRatio,
budgetTier,
@@ -26,6 +26,7 @@ import {
import { loadState } from '@/lib/load-state'
import { createVisibilityPoller } from '@/lib/polling'
import type { VisibilityPoller } from '@/lib/polling'
import { formatErrorRatePercent } from '@/lib/provider-rate'
import {
DEFAULT_TOP_USERS_MODE,
parseTopUsersMode,
@@ -37,12 +38,13 @@ import {
topUsersTotalsUrl,
} from '@/lib/top-users'
import type { TopUsersMode } from '@/lib/top-users'
import { microsToUsd } from '@/lib/usage-bars'
import { useAuthStore } from '@/stores/auth'
import { useDashboardStore } from '@/stores/dashboard'
import { useEventsStore } from '@/stores/events'
import { useFiltersStore } from '@/stores/filters'
import { useNavStore } from '@/stores/nav'
import type { AdminEventKind, AdminTotalsResponse, AdminUsageModelsResponse } from '@/types/api'
import type { AdminTotalsResponse, AdminUsageModelsResponse } from '@/types/api'
/**
* HomePage (redesign-plan.md section 3.4) is the redesign's landing
@@ -81,10 +83,10 @@ const spendDelta = computed<string>(() => {
const projectedMicros = computed<number | null>(() => projectMonthEnd(spendMtdMicros.value, monthProgress(new Date())))
const projectedHeadroom = computed(() =>
headroom(projectedMicros.value, budgetMicros.value > 0 ? budgetMicros.value / 1_000_000 : undefined),
headroom(projectedMicros.value, budgetMicros.value > 0 ? microsToUsd(budgetMicros.value) : undefined),
)
const projectedValue = computed<string>(() =>
projectedMicros.value === null ? 'not enough data yet' : formatCost(projectedMicros.value),
projectedMicros.value === null ? NOT_ENOUGH_DATA : formatCost(projectedMicros.value),
)
const projectedTier = computed(() => (projectedHeadroom.value.willExceed ? 'critical' : 'ok'))
const projectedDelta = computed<string | undefined>(() => {
@@ -101,16 +103,8 @@ const requestsPerMinute = computed<number>(() => dashboard.usage?.total.requests
const errorRate = computed(() => fleetErrorRate(dashboard.overview?.providers ?? []))
const errorTier = computed(() => errorRateTier(errorRate.value.rate))
const errorValue = computed<string>(() => {
const rate = errorRate.value.rate
if (rate === null) return 'no traffic'
if (rate <= 0) return '0%'
// Ceiling, not the usual floor: a genuinely non-zero error rate must
// never round down to a misleadingly clean "0%" (the opposite honesty
// direction from a SUCCESS rate, which floors so it never overstates
// — see lib/provider-rate.ts's own formatRatePercent doc comment).
return `${Math.ceil(rate * 100)}%`
})
/** errorValue reuses lib/provider-rate.ts's shared formatErrorRatePercent (reuse-audit.md F13) — the Home tile's own "no traffic" null wording is the ONE difference from the Reliability/Models pages' "no data" default, passed via the nullLabel param rather than a second, hand-rolled copy of the same ceiling-percent formatting. */
const errorValue = computed<string>(() => formatErrorRatePercent(errorRate.value.rate, 'no traffic'))
const errorDelta = computed<string>(() => `${errorRate.value.failures}/${errorRate.value.attempts} attempts in the last minute`)
// --- Open breakers / warnings / unpriced ---------------------------------
@@ -308,7 +302,7 @@ async function fetchTopUsers(): Promise<void> {
topUsersRangeTooLarge.value = true
topUsersError.value = ''
} else {
topUsersError.value = err instanceof Error ? err.message : String(err)
topUsersError.value = messageOf(err)
}
} finally {
if (requestId === topUsersReqId) topUsersLoading.value = false
@@ -334,7 +328,7 @@ async function fetchTopModels(): Promise<void> {
topModelsLoaded.value = true
} catch (err) {
if (requestId !== topModelsReqId) return
topModelsError.value = err instanceof Error ? err.message : String(err)
topModelsError.value = messageOf(err)
} finally {
if (requestId === topModelsReqId) topModelsLoading.value = false
}
@@ -383,11 +377,6 @@ watch(
// --- Latest 8 events -------------------------------------------------------
/** kindLabel falls back to the raw kind string for a kind EVENT_KIND_LABEL does not know (a server newer than this webui build) — same forward-compat convention lib/events-columns.ts's own identical helper documents. */
function kindLabel(kind: string): string {
return EVENT_KIND_LABEL[kind as AdminEventKind] ?? kind
}
const latestEvents = computed(() => events.events.slice(0, 8))
/** eventsCardState (states-plan.md gap item: "Home Latest events card still says 'No events recorded yet.' while loading or on error") mirrors EventsView.vue's own eventsLoadState — hasData is "has this store ever completed a fetch", not "are there events", so a clean fleet with lastUpdated set still reads 'ready' and falls through to noEventsRecorded below, not a permanent skeleton. */
@@ -498,10 +487,8 @@ onUnmounted(() => {
<CardTitle>Latest events</CardTitle>
</CardHeader>
<CardContent>
<SkeletonList v-if="eventsCardState === 'skeleton'" :rows="4" />
<ErrorState v-else-if="eventsCardState === 'error'" :message="events.error" :on-retry="events.refresh" />
<p v-else-if="noEventsRecorded" class="text-sm text-muted-foreground">No events recorded yet.</p>
<ol v-else class="flex flex-col gap-2">
<LoadStateView :state="eventsCardState" :error="events.error" :on-retry="events.refresh" skeleton="list" :rows="4" :empty="noEventsRecorded" empty-title="No events recorded yet.">
<ol class="flex flex-col gap-2">
<li
v-for="(event, i) in latestEvents"
:key="`${event.time}-${event.replica}-${i}`"
@@ -514,6 +501,7 @@ onUnmounted(() => {
<span class="shrink-0 tabular-nums text-muted-foreground">{{ formatTimestamp(event.time) }}</span>
</li>
</ol>
</LoadStateView>
</CardContent>
</Card>
</div>
+82 -114
View File
@@ -1,26 +1,29 @@
<script setup lang="ts">
import { computed, onMounted, watch } from 'vue'
import ChartCard from '@/components/ChartCard.vue'
import EmptyState from '@/components/EmptyState.vue'
import ErrorState from '@/components/ErrorState.vue'
import EventsView from '@/components/EventsView.vue'
import LabeledSelect from '@/components/LabeledSelect.vue'
import SkeletonChart from '@/components/SkeletonChart.vue'
import SkeletonList from '@/components/SkeletonList.vue'
import StatDisabledAlert from '@/components/StatDisabledAlert.vue'
import TimeSeriesChart from '@/components/TimeSeriesChart.vue'
import type { TimeSeriesDataset } from '@/components/TimeSeriesChart.vue'
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
import { Button } from '@/components/ui/button'
import { Alert, AlertDescription } 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 { seriesColor } from '@/lib/chart-palette'
import { formatBucketLabel } from '@/lib/format'
import { loadState } from '@/lib/load-state'
import type { LoadState } from '@/lib/load-state'
import { formatErrorRatePercent } from '@/lib/provider-rate'
import { scopeId } from '@/lib/scope'
import { useDashboardStore } from '@/stores/dashboard'
import { useEventsStore } from '@/stores/events'
import { useFiltersStore } from '@/stores/filters'
import { useNavStore } from '@/stores/nav'
import { seriesColor, useReliabilityStore } from '@/stores/reliability'
import { useReliabilityStore } from '@/stores/reliability'
import type { CountSeries, ProviderRatioSeries } from '@/stores/reliability'
/**
@@ -37,9 +40,6 @@ import type { CountSeries, ProviderRatioSeries } from '@/stores/reliability'
* round-trip through the URL hash the same way EntityLink.vue's params do
* — nav.goTo on every local change, read back via nav.params on mount.
*/
/** '' is the "every provider" state (selectedProvider/filterSelected below). Radix/reka-ui Select items cannot use an empty-string value (EventsView.vue's own identical ALL_KINDS doc comment) — ALL_PROVIDERS_OPTION is the Select-only sentinel, mapped to/from '' at the template boundary. */
const ALL_PROVIDERS_OPTION = 'all'
const dashboard = useDashboardStore()
const events = useEventsStore()
const filters = useFiltersStore()
@@ -47,16 +47,16 @@ const nav = useNavStore()
const reliability = useReliabilityStore()
const selectedProvider = computed<string>(() => nav.params.provider ?? '')
const providerSelectValue = computed<string>(() => selectedProvider.value || ALL_PROVIDERS_OPTION)
/** providerOptions feeds LabeledSelect's own options list — LabeledSelect owns the "All providers" -> '' sentinel mapping itself (reuse-audit.md F11), so this page no longer needs its own ALL_PROVIDERS_OPTION constant. */
const providerOptions = computed(() => (dashboard.overview?.providers ?? []).map((p) => ({ value: p.name, label: p.name })))
function onProviderChange(value: unknown): void {
if (typeof value !== 'string') return
nav.goTo('reliability', { ...nav.params, provider: value === ALL_PROVIDERS_OPTION ? '' : value })
function onProviderChange(value: string): void {
nav.goTo('reliability', { ...nav.params, provider: value })
}
/** providerLabel strips the "provider:" scope prefix every reliability series carries — the display label is just the provider name. */
/** providerLabel strips the "provider:" scope prefix every reliability series carries (lib/scope.ts's shared scopeId, reuse-audit.md F5) — the display label is just the provider name. */
function providerLabel(scope: string): string {
return scope.replace(/^provider:/, '')
return scopeId(scope)
}
function filterSelected<T extends { scope: string }>(series: T[]): T[] {
@@ -261,24 +261,20 @@ onMounted(() => void reliability.fetch())
<CardTitle>Reliability</CardTitle>
<CardDescription>Provider error rate, failovers, timeouts, rate-limit rejections, and 402 refusals.</CardDescription>
</div>
<div class="flex flex-col gap-1">
<label id="reliability-provider-label" class="text-xs font-medium text-muted-foreground">Provider</label>
<Select :model-value="providerSelectValue" @update:model-value="onProviderChange">
<SelectTrigger aria-labelledby="reliability-provider-label" class="w-48">
<SelectValue placeholder="All providers" />
</SelectTrigger>
<SelectContent>
<SelectItem :value="ALL_PROVIDERS_OPTION">All providers</SelectItem>
<SelectItem v-for="p in dashboard.overview?.providers ?? []" :key="p.name" :value="p.name">{{ p.name }}</SelectItem>
</SelectContent>
</Select>
</div>
<LabeledSelect
label="Provider"
:model-value="selectedProvider"
:options="providerOptions"
all-label="All providers"
trigger-class="w-48"
@update:model-value="onProviderChange"
/>
</CardHeader>
<CardContent>
<SkeletonList v-if="dashboardLoadState === 'skeleton'" :rows="2" />
<ErrorState v-else-if="dashboardLoadState === 'error'" :message="dashboard.error" :on-retry="dashboard.refresh" />
<p v-else-if="reliability.error" class="text-sm break-words text-destructive">{{ reliability.error }}</p>
<p v-else-if="!hasProviders" class="text-sm text-muted-foreground">No providers configured.</p>
<EmptyState v-else-if="!hasProviders" title="No providers configured." />
</CardContent>
</Card>
@@ -299,30 +295,10 @@ onMounted(() => void reliability.fetch())
actual charts below exactly as before.
-->
<template v-if="providersState === 'skeleton'">
<Card>
<CardHeader>
<CardTitle>Error rate by provider</CardTitle>
</CardHeader>
<CardContent><SkeletonChart /></CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Selected provider performance</CardTitle>
</CardHeader>
<CardContent><SkeletonChart /></CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Timeouts by provider</CardTitle>
</CardHeader>
<CardContent><SkeletonChart /></CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Failovers by provider</CardTitle>
</CardHeader>
<CardContent><SkeletonChart /></CardContent>
</Card>
<ChartCard title="Error rate by provider" state="skeleton" />
<ChartCard title="Selected provider performance" state="skeleton" />
<ChartCard title="Timeouts by provider" state="skeleton" />
<ChartCard title="Failovers by provider" state="skeleton" />
</template>
<Card v-else-if="providersState === 'empty'">
<CardContent>
@@ -333,17 +309,17 @@ onMounted(() => void reliability.fetch())
</CardContent>
</Card>
<template v-else-if="providersState === 'ready'">
<Card>
<CardHeader>
<CardTitle>Error rate by provider</CardTitle>
<CardDescription>Failed attempts / total attempts per bucket. A gap means no attempts in that bucket.</CardDescription>
</CardHeader>
<CardContent>
<SkeletonChart v-if="reliabilityState === 'skeleton'" />
<ErrorState v-else-if="reliabilityState === 'error'" :message="reliability.error" :on-retry="reliability.fetch" />
<TimeSeriesChart v-else :labels="bucketLabels" :datasets="errorRateDatasets" :value-formatter="errorRatePercent" ariaLabel="Error rate by provider over time" />
</CardContent>
</Card>
<ChartCard
title="Error rate by provider"
description="Failed attempts / total attempts per bucket. A gap means no attempts in that bucket."
:state="reliabilityState"
:error="reliability.error"
:on-retry="reliability.fetch"
:labels="bucketLabels"
:datasets="errorRateDatasets"
:value-formatter="errorRatePercent"
aria-label="Error rate by provider over time"
/>
<Card>
<CardHeader>
@@ -354,16 +330,12 @@ onMounted(() => void reliability.fetch())
<p v-if="selectedProvider === ''" class="text-sm text-muted-foreground">
Select a provider above to see its latency and failure breakdown per bucket.
</p>
<Alert v-else-if="!latencyStatsEnabled" variant="warn">
<AlertTitle>Latency statistics are off</AlertTitle>
<AlertDescription class="flex flex-wrap items-center gap-2">
<span
>Enable <code class="font-mono text-xs">admin.stats.latency</code> in the middleware config to see per-bucket latency
for {{ selectedProvider }}.</span
>
<Button type="button" variant="outline" size="sm" @click="nav.goTo('config')">Open Config</Button>
</AlertDescription>
</Alert>
<StatDisabledAlert
v-else-if="!latencyStatsEnabled"
title="Latency statistics are off"
config-key="admin.stats.latency"
:purpose="`to see per-bucket latency for ${selectedProvider}.`"
/>
<template v-else-if="providerPerfAvailable">
<SkeletonChart v-if="providerPerfState === 'skeleton'" />
<ErrorState
@@ -393,54 +365,50 @@ onMounted(() => void reliability.fetch())
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Timeouts by provider</CardTitle>
</CardHeader>
<CardContent>
<SkeletonChart v-if="reliabilityState === 'skeleton'" />
<ErrorState v-else-if="reliabilityState === 'error'" :message="reliability.error" :on-retry="reliability.fetch" />
<TimeSeriesChart v-else :labels="bucketLabels" :datasets="timeoutDatasets" ariaLabel="Timeouts by provider over time" />
</CardContent>
</Card>
<ChartCard
title="Timeouts by provider"
:state="reliabilityState"
:error="reliability.error"
:on-retry="reliability.fetch"
:labels="bucketLabels"
:datasets="timeoutDatasets"
aria-label="Timeouts by provider over time"
/>
<Card>
<CardHeader>
<CardTitle>Failovers by provider</CardTitle>
<CardDescription v-if="noFailoverConfigured">This deployment has no failover chains configured.</CardDescription>
</CardHeader>
<CardContent>
<SkeletonChart v-if="reliabilityState === 'skeleton'" />
<ErrorState v-else-if="reliabilityState === 'error'" :message="reliability.error" :on-retry="reliability.fetch" />
<TimeSeriesChart v-else :labels="bucketLabels" :datasets="failoverDatasets" ariaLabel="Failovers by provider over time" />
</CardContent>
</Card>
<ChartCard
title="Failovers by provider"
:description="noFailoverConfigured ? 'This deployment has no failover chains configured.' : undefined"
:state="reliabilityState"
:error="reliability.error"
:on-retry="reliability.fetch"
:labels="bucketLabels"
:datasets="failoverDatasets"
aria-label="Failovers by provider over time"
/>
</template>
<div class="grid gap-6 lg:grid-cols-2">
<Card>
<CardHeader>
<CardTitle>Rate-limit rejections (429)</CardTitle>
<CardDescription>Fleet-wide, every scope combined.</CardDescription>
</CardHeader>
<CardContent>
<SkeletonChart v-if="reliabilityState === 'skeleton'" />
<ErrorState v-else-if="reliabilityState === 'error'" :message="reliability.error" :on-retry="reliability.fetch" />
<TimeSeriesChart v-else :labels="bucketLabels" :datasets="rejectionDatasets" ariaLabel="Rate-limit rejections over time, fleet-wide" />
</CardContent>
</Card>
<ChartCard
title="Rate-limit rejections (429)"
description="Fleet-wide, every scope combined."
:state="reliabilityState"
:error="reliability.error"
:on-retry="reliability.fetch"
:labels="bucketLabels"
:datasets="rejectionDatasets"
aria-label="Rate-limit rejections over time, fleet-wide"
/>
<Card>
<CardHeader>
<CardTitle>Unpriced refusals (402)</CardTitle>
<CardDescription>Fleet-wide requests refused for having no resolvable price.</CardDescription>
</CardHeader>
<CardContent>
<SkeletonChart v-if="reliabilityState === 'skeleton'" />
<ErrorState v-else-if="reliabilityState === 'error'" :message="reliability.error" :on-retry="reliability.fetch" />
<TimeSeriesChart v-else :labels="bucketLabels" :datasets="unpricedDatasets" ariaLabel="Unpriced refusals (402) over time, fleet-wide" />
</CardContent>
</Card>
<ChartCard
title="Unpriced refusals (402)"
description="Fleet-wide requests refused for having no resolvable price."
:state="reliabilityState"
:error="reliability.error"
:on-retry="reliability.fetch"
:labels="bucketLabels"
:datasets="unpricedDatasets"
aria-label="Unpriced refusals (402) over time, fleet-wide"
/>
</div>
<EventsView />
+28 -85
View File
@@ -3,19 +3,19 @@ import { computed, onMounted, onUnmounted, watch } from 'vue'
import AttributionDrilldown from '@/components/AttributionDrilldown.vue'
import BurnDownChart from '@/components/BurnDownChart.vue'
import ChartCard from '@/components/ChartCard.vue'
import CostAvoidedCard from '@/components/CostAvoidedCard.vue'
import CostForecastCard from '@/components/CostForecastCard.vue'
import ErrorState from '@/components/ErrorState.vue'
import LabeledSelect from '@/components/LabeledSelect.vue'
import PricingHealthTable from '@/components/PricingHealthTable.vue'
import SkeletonChart from '@/components/SkeletonChart.vue'
import TimeSeriesChart from '@/components/TimeSeriesChart.vue'
import type { TimeSeriesComparisonDataset, TimeSeriesDataset } from '@/components/TimeSeriesChart.vue'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs'
import { useNow } from '@/composables/useNow'
import { cacheSavingsStatus } from '@/lib/cache-savings'
import { BREAKDOWN_PALETTE, seriesColor } from '@/lib/chart-palette'
import { formatBucketLabel, formatCompactCount, formatCost } from '@/lib/format'
import { loadState } from '@/lib/load-state'
import { parseScope, scopeBudgetLimits, scopeId } from '@/lib/scope'
import { useCatalogStore } from '@/stores/catalog'
import { useDashboardStore } from '@/stores/dashboard'
import { useFiltersStore } from '@/stores/filters'
@@ -32,6 +32,7 @@ import type { SpendBy } from '@/stores/spend'
import type { HistoryMetric, LimitsConfig } from '@/types/api'
const SPEND_BY_LABEL: Record<SpendBy, string> = { model: 'By model', provider: 'By provider', group: 'By group' }
const metricOptions = SPEND_METRICS.map((m) => ({ value: m, label: SPEND_METRIC_LABEL[m] }))
const nav = useNavStore()
const filters = useFiltersStore()
@@ -105,43 +106,9 @@ onUnmounted(() => {
// --- breakdown chart ---
/**
* BREAKDOWN_PALETTE cycles a colorblind-safe (Okabe & Ito, 2008) 8-color
* qualitative set across the breakdown chart's per-model/provider/group
* series. The first four reuse main.css's own existing --chart-* tokens
* (already that same palette's requests/tokens-in/tokens-out/cost
* members); the remaining four (--chart-series-a..d) extend it, defined
* alongside them in assets/main.css — main.css's original four tokens
* were sized for four FIXED semantic metrics, not an open-ended
* breakdown series count, so this page needed four more, but every chart
* color token lives in the one design-tokens file (Tailwind-only rule),
* never a component-local <style> block. Beyond 8 series (e.g. a fleet
* with more than 8 configured groups) colors repeat — an accepted
* qualitative-palette limit, not a bug: legend labels remain the
* disambiguator past that point, the same tradeoff any categorical chart
* palette makes once it runs out of distinguishable hues.
*/
const BREAKDOWN_PALETTE = [
'--chart-requests',
'--chart-tokens-in',
'--chart-tokens-out',
'--chart-cost',
'--chart-series-a',
'--chart-series-b',
'--chart-series-c',
'--chart-series-d',
]
function paletteColor(index: number): string {
return BREAKDOWN_PALETTE[index % BREAKDOWN_PALETTE.length]!
}
/** breakdownLabel strips a series' own scope-kind prefix ("model:"/"group:") for the chart legend — sumByProvider's own output already has no prefix (lib/series.ts). */
/** breakdownLabel strips a series' own scope-kind prefix ("model:"/"group:"/"provider:") for the chart legend (lib/scope.ts's shared scopeId, reuse-audit.md F5) — sumByProvider's own output already has no prefix (lib/series.ts). */
function breakdownLabel(scope: string): string {
if (scope.startsWith('model:')) return scope.slice('model:'.length)
if (scope.startsWith('group:')) return scope.slice('group:'.length)
if (scope.startsWith('provider:')) return scope.slice('provider:'.length)
return scope
return scopeId(scope)
}
// The breakdown chart's own window (filters.window) decides the bucket
@@ -153,7 +120,7 @@ const breakdownDatasets = computed<TimeSeriesDataset[]>(() => {
const charted: TimeSeriesDataset[] = spend.breakdown.map((s, i) => ({
label: breakdownLabel(s.scope),
data: s.points,
color: paletteColor(i),
color: seriesColor(i, BREAKDOWN_PALETTE),
}))
if (spend.otherPoints) charted.push({ label: 'Other', data: spend.otherPoints, color: '--muted-foreground' })
return charted
@@ -173,38 +140,23 @@ const totalInRange = computed<number>(() => spend.totalPoints.reduce((a, b) => a
// --- forecast / burn-down scope derivation (Q2/DECISIONS: fleet budget = sum of group costPerMonthUSD) ---
const scopeMtdMicros = computed<number>(() => {
if (filters.scope === 'all') return dashboard.usage?.total.costPerMonthMicroUsd ?? 0
if (filters.scope.startsWith('group:')) {
const name = filters.scope.slice('group:'.length)
return dashboard.usage?.groups.find((g) => g.id === name)?.costPerMonthMicroUsd ?? 0
}
if (filters.scope.startsWith('user:')) {
const name = filters.scope.slice('user:'.length)
return dashboard.usage?.users.find((u) => u.id === name)?.costPerMonthMicroUsd ?? 0
}
const parsed = parseScope(filters.scope)
if (!parsed) return dashboard.usage?.total.costPerMonthMicroUsd ?? 0
if (parsed.kind === 'group') return dashboard.usage?.groups.find((g) => g.id === parsed.id)?.costPerMonthMicroUsd ?? 0
if (parsed.kind === 'user') return dashboard.usage?.users.find((u) => u.id === parsed.id)?.costPerMonthMicroUsd ?? 0
return 0
})
const scopeLimits = computed<LimitsConfig | undefined>(() => {
if (filters.scope === 'all') {
const groups = dashboard.overview?.groups ?? []
const budgeted = groups.filter((g) => (g.limits?.costPerMonthUSD ?? 0) > 0)
if (budgeted.length === 0) return undefined
const totalUsd = budgeted.reduce((sum, g) => sum + (g.limits?.costPerMonthUSD ?? 0), 0)
return { costPerMonthUSD: totalUsd }
}
if (filters.scope.startsWith('group:')) {
const name = filters.scope.slice('group:'.length)
return dashboard.overview?.groups.find((g) => g.name === name)?.limits
}
if (filters.scope.startsWith('user:')) {
const name = filters.scope.slice('user:'.length)
return dashboard.usage?.users.find((u) => u.id === name)?.limits
}
return undefined
})
/** scopeLimits resolves the current global scope's own cost/month LimitsConfig via lib/scope.ts's shared scopeBudgetLimits (reuse-audit.md F5) — the fleet-wide sum-of-group-budgets 'all' case and the per-group/user lookup both used to be reimplemented here, disagreeing in spots with stores/spend.ts's own third copy. */
const scopeLimits = computed<LimitsConfig | undefined>(() =>
scopeBudgetLimits(filters.scope, dashboard.overview?.groups ?? [], dashboard.usage?.users ?? []),
)
const forecastAvailable = computed<boolean>(() => filters.scope === 'all' || filters.scope.startsWith('group:') || filters.scope.startsWith('user:'))
/** forecastAvailable is true for every scope scopeLimits/scopeMtdMicros can resolve a figure for — 'all', a group, or a user; false only for a provider scope, which has no fleet-relative budget concept. */
const forecastAvailable = computed<boolean>(() => {
const parsed = parseScope(filters.scope)
return !parsed || parsed.kind === 'group' || parsed.kind === 'user'
})
// --- attribution drilldown gating ---
@@ -263,17 +215,7 @@ function retryPricingHealthTable(): Promise<void> {
</TabsList>
</Tabs>
</div>
<div class="flex flex-col gap-1">
<label id="spend-metric-label" class="text-xs font-medium text-muted-foreground">Metric</label>
<Select :model-value="metric" @update:model-value="onMetricUpdate">
<SelectTrigger aria-labelledby="spend-metric-label" class="w-40">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem v-for="m in SPEND_METRICS" :key="m" :value="m">{{ SPEND_METRIC_LABEL[m] }}</SelectItem>
</SelectContent>
</Select>
</div>
<LabeledSelect label="Metric" :model-value="metric" :options="metricOptions" trigger-class="w-40" @update:model-value="onMetricUpdate" />
</div>
<div class="text-right">
<p class="text-xs font-medium text-muted-foreground">{{ SPEND_METRIC_LABEL[metric] }} in range</p>
@@ -312,16 +254,17 @@ function retryPricingHealthTable(): Promise<void> {
{{ spend.comparisonUnavailableReason }}
</p>
<SkeletonChart v-if="breakdownState === 'skeleton'" />
<ErrorState v-else-if="breakdownState === 'error'" :message="spend.breakdownError" :on-retry="spend.refresh" />
<TimeSeriesChart
v-else
<ChartCard
bare
:state="breakdownState"
:error="spend.breakdownError"
:on-retry="spend.refresh"
:labels="breakdownLabels"
:datasets="breakdownDatasets"
:comparison-datasets="comparisonDatasets"
stacked
:value-formatter="valueFormatter"
:ariaLabel="`${SPEND_METRIC_LABEL[metric]} breakdown ${SPEND_BY_LABEL[by].toLowerCase()} over time`"
:aria-label="`${SPEND_METRIC_LABEL[metric]} breakdown ${SPEND_BY_LABEL[by].toLowerCase()} over time`"
/>
<!-- verify-ui-states-3.md NEW-1: a background refetch failure (poll
tick, or a metric/by/range/scope change) while the PREVIOUS selection's
+3 -3
View File
@@ -1,6 +1,6 @@
import { defineStore } from 'pinia'
import { AdminApiError, adminFetch } from '@/lib/api'
import { adminFetch, isAuthRejection, messageOf } from '@/lib/api'
import { useAuthStore } from '@/stores/auth'
import type { AdminCatalogModel, AdminCatalogResponse } from '@/types/api'
@@ -86,10 +86,10 @@ export const useCatalogStore = defineStore('catalog', {
this.error = ''
} catch (err) {
if (requestId !== this.reqId) return
if (err instanceof AdminApiError && (err.status === 401 || err.status === 403)) {
if (isAuthRejection(err)) {
return
}
this.error = err instanceof Error ? err.message : String(err)
this.error = messageOf(err)
} finally {
if (requestId === this.reqId) this.loading = false
}
+3 -3
View File
@@ -1,6 +1,6 @@
import { defineStore } from 'pinia'
import { AdminApiError, adminFetch } from '@/lib/api'
import { adminFetch, isAuthRejection, messageOf } from '@/lib/api'
import { useAuthStore } from '@/stores/auth'
import type { AdminConfigResponse } from '@/types/api'
@@ -29,8 +29,8 @@ export const useConfigStore = defineStore('config', {
this.data = await adminFetch<AdminConfigResponse>('/admin/api/config')
this.error = ''
} catch (err) {
if (err instanceof AdminApiError && (err.status === 401 || err.status === 403)) return
this.error = err instanceof Error ? err.message : String(err)
if (isAuthRejection(err)) return
this.error = messageOf(err)
} finally {
this.loading = false
}
+5 -7
View File
@@ -1,6 +1,6 @@
import { defineStore } from 'pinia'
import { AdminApiError, adminFetch } from '@/lib/api'
import { AdminApiError, adminFetch, isAuthRejection, messageOf } from '@/lib/api'
import { dayOrMonthWindow, seriesUrl, totalsUrl } from '@/lib/range'
import { useAuthStore } from '@/stores/auth'
import type { AdminConsumersResponse, AdminSeriesResponse, AdminTotalsResponse, HistoryWindow } from '@/types/api'
@@ -63,7 +63,7 @@ function emptyDetail(): UserDetailState {
/** settledError reads one Error message off a rejected PromiseSettledResult, or '' for a fulfilled one. */
function settledError(result: PromiseSettledResult<unknown>): string {
if (result.status !== 'rejected') return ''
return result.reason instanceof Error ? result.reason.message : String(result.reason)
return messageOf(result.reason)
}
/**
@@ -102,8 +102,8 @@ export const useConsumersStore = defineStore('consumers', {
this.error = ''
this.fetchedAt = Date.now()
} catch (err) {
if (err instanceof AdminApiError && (err.status === 401 || err.status === 403)) return
this.error = err instanceof Error ? err.message : String(err)
if (isAuthRejection(err)) return
this.error = messageOf(err)
} finally {
this.loading = false
}
@@ -184,9 +184,7 @@ export const useConsumersStore = defineStore('consumers', {
// superseded this one (latest-request-wins) — drop this result.
if (this.detail[userId]?.reqId !== requestId) return
const authRejected = [reqResult, costResult, modelResult].some(
(r) => r.status === 'rejected' && r.reason instanceof AdminApiError && (r.reason.status === 401 || r.reason.status === 403),
)
const authRejected = [reqResult, costResult, modelResult].some((r) => r.status === 'rejected' && isAuthRejection(r.reason))
if (authRejected) return
const modelTotalsUnavailable =
+3 -7
View File
@@ -1,6 +1,6 @@
import { defineStore } from 'pinia'
import { AdminApiError, adminFetch } from '@/lib/api'
import { adminFetch, isAuthRejection, messageOf } from '@/lib/api'
import { createVisibilityPoller, type VisibilityPoller } from '@/lib/polling'
import { useAuthStore } from '@/stores/auth'
import { useToastsStore } from '@/stores/toasts'
@@ -98,18 +98,14 @@ export const useDashboardStore = defineStore('dashboard', {
// A 401/403 on ANY section already rejected the key (lib/api.ts) —
// the AuthGate takes over the whole view in that case, matching the
// pre-existing single-Promise.all behavior; nothing left to report.
const authRejected = failures.some(
({ reason }) => reason instanceof AdminApiError && (reason.status === 401 || reason.status === 403),
)
const authRejected = failures.some(({ reason }) => isAuthRejection(reason))
if (authRejected) return
// A partial failure still advances lastUpdated: at least one
// section genuinely has fresh data, even though `error` (below)
// still surfaces that something is degraded.
if (failures.length < 3) this.lastUpdated = new Date()
this.error = failures
.map(({ label, reason }) => `${label}: ${reason instanceof Error ? reason.message : String(reason)}`)
.join('; ')
this.error = failures.map(({ label, reason }) => `${label}: ${messageOf(reason)}`).join('; ')
// states-plan.md item 3: a background refresh failure while data is
// ALREADY shown gets a toast instead of wiping content (the header's
+3 -3
View File
@@ -1,6 +1,6 @@
import { defineStore } from 'pinia'
import { AdminApiError, adminFetch } from '@/lib/api'
import { adminFetch, isAuthRejection, messageOf } from '@/lib/api'
import { createVisibilityPoller, type VisibilityPoller } from '@/lib/polling'
import { useAuthStore } from '@/stores/auth'
import { useToastsStore } from '@/stores/toasts'
@@ -87,8 +87,8 @@ export const useEventsStore = defineStore('events', {
// 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)
if (isAuthRejection(err)) return
this.error = messageOf(err)
// states-plan.md item 3 — see dashboard.ts's doRefresh for the
// full reasoning: only when the reader already had a successful
// fetch to show, and only once per ongoing failure streak.
+3 -3
View File
@@ -1,6 +1,6 @@
import { defineStore } from 'pinia'
import { AdminApiError, adminFetch } from '@/lib/api'
import { adminFetch, isAuthRejection, messageOf } from '@/lib/api'
import { buildModelCatalogRows } from '@/lib/model-table-columns'
import type { ModelCatalogRow } from '@/lib/model-table-columns'
import { useCatalogStore } from '@/stores/catalog'
@@ -73,8 +73,8 @@ export const useModelsStore = defineStore('models', {
this.error = ''
} catch (err) {
if (requestId !== this.reqId) return
if (err instanceof AdminApiError && (err.status === 401 || err.status === 403)) return
this.error = err instanceof Error ? err.message : String(err)
if (isAuthRejection(err)) return
this.error = messageOf(err)
} finally {
if (requestId === this.reqId) this.loading = false
}
+5 -11
View File
@@ -1,8 +1,10 @@
// reliability.spec.ts covers the pure helpers (hourOrDayWindow,
// errorRateSeries, seriesColor) directly, plus useReliabilityStore.fetch's
// errorRateSeries) directly, plus useReliabilityStore.fetch's
// provider-scope-list-from-dashboard-overview wiring and its zero-providers
// short-circuit — mirroring dashboard.spec.ts's own mocked-adminFetch,
// Pinia-under-vitest approach (that file's own doc comment).
// Pinia-under-vitest approach (that file's own doc comment). seriesColor/
// CHART_SERIES_COLORS live in, and are covered by, lib/chart-palette.spec.ts
// — this store no longer re-exports them (reuse-audit.md F13).
import { createPinia, setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
@@ -13,7 +15,7 @@ vi.mock('@/lib/api', async () => {
})
import { adminFetch } from '@/lib/api'
import { errorRateSeries, hourOrDayWindow, RELIABILITY_SERIES_COLORS, seriesColor, useReliabilityStore } from '@/stores/reliability'
import { errorRateSeries, hourOrDayWindow, useReliabilityStore } from '@/stores/reliability'
import { useAuthStore } from '@/stores/auth'
import { useDashboardStore } from '@/stores/dashboard'
import type { AdminOverviewResponse, AdminPerfResponse, AdminPerfRow, AdminSeriesResponse } from '@/types/api'
@@ -67,14 +69,6 @@ describe('errorRateSeries', () => {
})
})
describe('seriesColor', () => {
it('cycles through RELIABILITY_SERIES_COLORS by index', () => {
expect(seriesColor(0)).toBe(RELIABILITY_SERIES_COLORS[0])
expect(seriesColor(RELIABILITY_SERIES_COLORS.length)).toBe(RELIABILITY_SERIES_COLORS[0])
expect(seriesColor(RELIABILITY_SERIES_COLORS.length + 1)).toBe(RELIABILITY_SERIES_COLORS[1])
})
})
const emptyOverview: AdminOverviewResponse = {
version: '1',
replica: 'pod-a',
+5 -20
View File
@@ -1,6 +1,6 @@
import { defineStore } from 'pinia'
import { AdminApiError, adminFetch } from '@/lib/api'
import { adminFetch, isAuthRejection, messageOf } from '@/lib/api'
import { seriesUrl } from '@/lib/range'
import { ratioSeries } from '@/lib/series'
import { useDashboardStore } from '@/stores/dashboard'
@@ -53,21 +53,6 @@ export function errorRateSeries(attempts: AdminSeriesResponse, fails: AdminSerie
}))
}
/**
* RELIABILITY_SERIES_COLORS cycles through main.css's four existing
* --chart-* tokens (assets/main.css is owned by WP-E — this store does not
* add a fifth) to color the per-provider error-rate/timeout/failover
* overlay charts: with more than four configured providers, colors repeat
* — a known, documented limitation rather than a silent one, since adding
* a genuinely distinct fifth hue is out of this work package's file scope.
*/
export const RELIABILITY_SERIES_COLORS = ['--chart-requests', '--chart-tokens-in', '--chart-tokens-out', '--chart-cost'] as const
/** seriesColor picks one of RELIABILITY_SERIES_COLORS by index, cycling. */
export function seriesColor(index: number): string {
return RELIABILITY_SERIES_COLORS[index % RELIABILITY_SERIES_COLORS.length]
}
/** One plain (non-ratio) metric series, e.g. failovers/timeouts per provider, or the fleet-wide rej/r402 total. */
export interface CountSeries {
scope: string
@@ -179,8 +164,8 @@ export const useReliabilityStore = defineStore('reliability', {
this.error = ''
} catch (err) {
if (requestId !== this.reqId) return
if (err instanceof AdminApiError && (err.status === 401 || err.status === 403)) return
this.error = err instanceof Error ? err.message : String(err)
if (isAuthRejection(err)) return
this.error = messageOf(err)
} finally {
if (requestId === this.reqId) this.loading = false
}
@@ -232,8 +217,8 @@ export const useReliabilityStore = defineStore('reliability', {
this.providerPerfError = ''
} catch (err) {
if (requestId !== this.providerPerfReqId) return
if (err instanceof AdminApiError && (err.status === 401 || err.status === 403)) return
this.providerPerfError = err instanceof Error ? err.message : String(err)
if (isAuthRejection(err)) return
this.providerPerfError = messageOf(err)
} finally {
if (requestId === this.providerPerfReqId) this.providerPerfLoading = false
}
+13 -33
View File
@@ -1,12 +1,13 @@
import { defineStore } from 'pinia'
import { AdminApiError, adminFetch } from '@/lib/api'
import { AdminApiError, adminFetch, isAuthRejection, messageOf } from '@/lib/api'
import { currentMonthDaySpan } from '@/lib/burndown'
import { sumGroupBudgetMicros } from '@/lib/kpi'
import { createVisibilityPoller, type VisibilityPoller } from '@/lib/polling'
import { dayOrMonthWindow, seriesUrl, totalsUrl } from '@/lib/range'
import type { TotalsUrlParams } from '@/lib/range'
import { drilldownLevel, scopeBudgetLimits } from '@/lib/scope'
import { other, stackTopN, sumByProvider, type Series } from '@/lib/series'
import { usdToMicros } from '@/lib/usage-bars'
import { useAuthStore } from '@/stores/auth'
import { useDashboardStore } from '@/stores/dashboard'
import { useFiltersStore } from '@/stores/filters'
@@ -618,13 +619,6 @@ export const useSpendStore = defineStore('spend', {
},
})
/** drilldownLevel derives AttributionDrilldown's current GET /admin/api/usage/totals request shape from the `drill` breadcrumb state (see useSpendStore's own state doc comment on `drill`). */
function drilldownLevel(drill: string): { kind: 'group' } | { kind: 'user'; group: string } | { kind: 'usermodel'; user: string } {
if (drill === '') return { kind: 'group' }
if (drill.startsWith('group:')) return { kind: 'user', group: drill.slice('group:'.length) }
return { kind: 'usermodel', user: drill.slice('user:'.length) }
}
/** burndownScopeParam maps the global scope filter to the /usage/series scope this store's burn-down line requests — null for a provider scope, which has no cost series and no budget concept (see useSpendStore's own doc comment). */
function burndownScopeParam(filtersScope: string): string | null {
if (filtersScope === 'all') return 'total'
@@ -634,32 +628,18 @@ function burndownScopeParam(filtersScope: string): string | null {
/**
* burndownBudgetMicros resolves the configured cost/month budget (micro-
* USD) for the burn-down line's own scope: the fleet-wide sum-of-group
* budgets (Q2/DECISIONS: lib/kpi.ts's sumGroupBudgetMicros) for `total`,
* or that one group's/user's own configured costPerMonthUSD limit
* otherwise — read from stores/dashboard.ts's already-polled overview/
* usage data rather than a second network round trip.
* USD) for the burn-down line's own scope, via lib/scope.ts's shared
* scopeBudgetLimits (reuse-audit.md F5 — the fleet-wide sum-of-group-
* budgets 'total' case and the per-group/user lookup both used to be
* reimplemented here a third time) — read from stores/dashboard.ts's
* already-polled overview/usage data rather than a second network round
* trip. `burndownScopeParam`'s own 'total' sentinel is scopeBudgetLimits'
* "unscoped root" case (same as filters.scope's 'all'); a provider scope
* never reaches here (burndownScopeParam itself returns null for one).
*/
function burndownBudgetMicros(scope: string): number | null {
const dashboard = useDashboardStore()
if (scope === 'total') {
const total = sumGroupBudgetMicros(dashboard.overview?.groups ?? [])
return total > 0 ? total : null
}
if (scope.startsWith('group:')) {
const name = scope.slice('group:'.length)
const usd = dashboard.overview?.groups.find((g) => g.name === name)?.limits?.costPerMonthUSD
return usd && usd > 0 ? Math.round(usd * 1_000_000) : null
}
const name = scope.slice('user:'.length)
const usd = dashboard.usage?.users.find((u) => u.id === name)?.limits?.costPerMonthUSD
return usd && usd > 0 ? Math.round(usd * 1_000_000) : null
const usd = scopeBudgetLimits(scope, dashboard.overview?.groups ?? [], dashboard.usage?.users ?? [])?.costPerMonthUSD
return usd && usd > 0 ? usdToMicros(usd) : null
}
function isAuthRejection(err: unknown): boolean {
return err instanceof AdminApiError && (err.status === 401 || err.status === 403)
}
function messageOf(err: unknown): string {
return err instanceof Error ? err.message : String(err)
}