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

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

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

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

Claude-Session: https://claude.ai/code/session_01V5jYJNxpDFdJkyyExpADrv
This commit is contained in:
2026-09-23 16:41:01 +01:00
parent e8abb91dc8
commit ee49467e64
127 changed files with 11226 additions and 3414 deletions
+114
View File
@@ -0,0 +1,114 @@
<script setup lang="ts">
import { faCheck, faTriangleExclamation, faXmark } from '@fortawesome/free-solid-svg-icons'
import { computed, onMounted } from 'vue'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
import { buildAccessMatrix } from '@/lib/access-matrix'
import type { AccessCell } from '@/lib/access-matrix'
import { useConsumersStore } from '@/stores/consumers'
import { useDashboardStore } from '@/stores/dashboard'
/**
* AccessMatrix (redesign-plan.md section 3.4) is the Consumers page's
* `view=matrix` alternative to ConsumerDirectory: rows are groups, columns
* are every configured provider (allowed check + "n/m models", from GET
* /admin/api/consumers' AllowedProviders/modelAccess) and every configured
* MCP server/agent target (from GET /admin/api/targets' own `access`
* list). Pure data shaping lives in lib/access-matrix.ts; this component
* only fetches, lays out columns, and renders.
*/
const consumers = useConsumersStore()
const dashboard = useDashboardStore()
onMounted(() => {
void consumers.ensureConsumers()
})
const providerNames = computed(() => (dashboard.overview?.providers ?? []).map((p) => p.name))
const mcpServers = computed(() => dashboard.targets?.mcpServers ?? [])
const agents = computed(() => dashboard.targets?.agents ?? [])
/** MatrixColumn flattens the three heterogeneous column groups (providers, MCP servers, agents) into one ordered list — a single v-for renders every header AND every cell, rather than three near-identical template blocks. */
interface MatrixColumn {
key: string
label: string
section: 'providers' | 'mcpServers' | 'agents'
}
const columns = computed<MatrixColumn[]>(() => [
...providerNames.value.map((name): MatrixColumn => ({ key: name, label: name, section: 'providers' })),
...mcpServers.value.map((t): MatrixColumn => ({ key: t.name, label: `${t.name} (MCP)`, section: 'mcpServers' })),
...agents.value.map((t): MatrixColumn => ({ key: t.name, label: `${t.name} (agent)`, section: 'agents' })),
])
const rows = computed(() => buildAccessMatrix(consumers.data?.groups ?? [], providerNames.value, mcpServers.value, agents.value))
const EMPTY_CELL: AccessCell = { allowed: false }
function cellFor(row: (typeof rows.value)[number], column: MatrixColumn): AccessCell {
return row[column.section][column.key] ?? EMPTY_CELL
}
/**
* cellSrText is the ONLY accessible text a screen reader announces for
* one cell — the visible icon is aria-hidden and the visible detail span
* is aria-hidden too (P3 item 18: it used to be read TWICE, once as
* plain visible text and again inside this string). A zero-model cell
* (`zero`) gets its own wording rather than the plain "Allowed, 0/5
* models" reading, which sounds identical to "Allowed, 5/5 models" to a
* screen reader user skimming past the number.
*/
function cellSrText(cell: AccessCell): string {
if (!cell.allowed) return 'Not allowed'
if (cell.zero) return `Allowed, but ${cell.detail} reachable — the model grant matches none of this provider's models`
return cell.detail ? `Allowed, ${cell.detail}` : 'Allowed'
}
/** cellIcon picks the FontAwesome icon for a cell's three visual states: not allowed (X), allowed-but-zero-usable-models (warning triangle — P3 item 18: this used to render an identical green check to a genuinely unrestricted cell), and ordinarily allowed (check). */
function cellIcon(cell: AccessCell) {
if (!cell.allowed) return faXmark
if (cell.zero) return faTriangleExclamation
return faCheck
}
/** cellIconClass colors cellIcon's three states distinctly — not-allowed and zero-but-allowed must never share the "everything is fine" green. */
function cellIconClass(cell: AccessCell): string {
if (!cell.allowed) return 'text-muted-foreground/40'
if (cell.zero) return 'text-status-warn'
return 'text-chart-requests'
}
</script>
<template>
<Card>
<CardHeader>
<CardTitle>Access matrix</CardTitle>
<CardDescription>Which groups can reach which providers, MCP servers, and agents.</CardDescription>
</CardHeader>
<CardContent v-if="consumers.error" class="text-sm text-destructive">{{ consumers.error }}</CardContent>
<CardContent v-else-if="rows.length === 0" class="py-6 text-center text-sm text-muted-foreground">
{{ consumers.loading ? 'loading…' : 'no groups configured' }}
</CardContent>
<CardContent v-else class="overflow-x-auto">
<Table>
<TableHeader>
<TableRow>
<TableHead>Group</TableHead>
<TableHead v-for="column in columns" :key="column.key + column.section">{{ column.label }}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
<TableRow v-for="row in rows" :key="row.group">
<TableHead scope="row" class="font-medium">{{ row.group }}</TableHead>
<TableCell v-for="column in columns" :key="column.key + column.section" class="text-center" :title="cellFor(row, column).detail">
<FontAwesomeIcon :icon="cellIcon(cellFor(row, column))" :class="cellIconClass(cellFor(row, column))" class="size-3.5" aria-hidden="true" />
<span v-if="cellFor(row, column).detail" aria-hidden="true" class="ml-1 text-xs text-muted-foreground">{{ cellFor(row, column).detail }}</span>
<span class="sr-only">{{ cellSrText(cellFor(row, column)) }}</span>
</TableCell>
</TableRow>
</TableBody>
</Table>
</CardContent>
</Card>
</template>
@@ -0,0 +1,163 @@
<script setup lang="ts">
import type { ColumnDef } from '@tanstack/vue-table'
import { faChevronRight } from '@fortawesome/free-solid-svg-icons'
import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome'
import { computed, h, ref } from 'vue'
import ClampedRangeNotice from '@/components/ClampedRangeNotice.vue'
import CompactNumber from '@/components/CompactNumber.vue'
import DataTable from '@/components/DataTable.vue'
import EntityLink from '@/components/EntityLink.vue'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { formatCost } from '@/lib/format'
import { SPEND_METRIC_LABEL } from '@/stores/spend'
import type { AdminTotalsResponse, HistoryMetric, HistoryWindow } from '@/types/api'
/** DrilldownRow is one GET /admin/api/usage/totals row (types/api.ts's AdminTotalsResponse.rows entry). */
type DrilldownRow = AdminTotalsResponse['rows'][number]
/**
* AttributionDrilldown (redesign-plan.md section 3.4) descends group ->
* users (GET /admin/api/usage/totals?kind=user&group=) -> models (kind=
* usermodel, only when AdminFeaturesView.userModelStats — the config's
* admin.stats.userModel), breadcrumbs tracked in the `drill` page param
* (section 3.1). This component owns no fetch itself — CONTROLLED, like
* CostAvoidedCard.vue's own `ref` prop — stores/spend.ts's fetchDrilldown
* reads `drill` and supplies `rows`/`metrics`/`disabled`; every click here
* only emits `update:drill`, letting SpendPage.vue keep it in nav.params
* (so a reload or shared link restores the same drilldown position).
*
* `userModelStatsEnabled` gates whether a USER row is clickable at all
* (redesign-plan.md's own "only when features.userModelStats, else hint
* naming the config key") — checked client-side from the already-polled
* overview response, before ever firing a request that would only come
* back 404. `disabled` (from the store, a REAL 404) is the fallback for
* the deep-link case: a reader who lands directly on `drill=user:x` via a
* restored hash before this prop has had a chance to gate the click.
*/
const props = defineProps<{
rows: DrilldownRow[]
metrics: string[]
drill: string
disabled: boolean
userModelStatsEnabled: boolean
/** The global filters store's current window/span (N3 fix) — ClampedRangeNotice's own props, forwarded so this stays a controlled, props-driven component rather than reaching into stores/filters.ts itself. */
window: HistoryWindow
span: number
}>()
const emit = defineEmits<{ 'update:drill': [value: string] }>()
type Level = { kind: 'group' } | { kind: 'user'; group: string } | { kind: 'usermodel'; user: string }
const level = computed<Level>(() => {
if (props.drill === '') return { kind: 'group' }
if (props.drill.startsWith('group:')) return { kind: 'user', group: props.drill.slice('group:'.length) }
return { kind: 'usermodel', user: props.drill.slice('user:'.length) }
})
/**
* parentGroupLabel is a BEST-EFFORT breadcrumb label for the group a
* drilled-into user belongs to — the `drill` param itself only ever
* encodes the current LEAF selection (`user:{id}`, redesign-plan.md
* section 3.1's own `drill=group:x|user:x` shape has no room for a
* compound path), so this is tracked as local component state, set at
* the moment a user row is clicked FROM the user-list level. A reader who
* lands directly on `drill=user:x` via a restored hash sees no middle
* breadcrumb segment — an acceptable, honest degradation (nothing else
* available in `drill` records it) rather than a fabricated group name.
*/
const parentGroupLabel = ref('')
function goToGroups(): void {
emit('update:drill', '')
}
function goToParentGroup(): void {
if (parentGroupLabel.value) emit('update:drill', `group:${parentGroupLabel.value}`)
}
function onRowClick(id: string): void {
const l = level.value
if (l.kind === 'group') {
emit('update:drill', `group:${id}`)
} else if (l.kind === 'user' && props.userModelStatsEnabled) {
parentGroupLabel.value = l.group
emit('update:drill', `user:${id}`)
}
}
const idColumnLabel = computed<string>(() => (level.value.kind === 'group' ? 'Group' : level.value.kind === 'user' ? 'User' : 'Model'))
/** Row id is clickable to drill further at the group level always, and at the user level only when userModelStatsEnabled — the leaf (usermodel) level renders the model id as an EntityLink to the Models page instead, since there is nowhere further to drill. */
function idCell(row: DrilldownRow) {
const l = level.value
if (l.kind === 'usermodel') return h(EntityLink, { label: row.id, kind: 'model', id: row.id })
const clickable = l.kind === 'group' || props.userModelStatsEnabled
if (!clickable) return h('span', {}, row.id)
return h(
'button',
{
type: 'button',
class:
'inline-flex cursor-pointer items-center gap-1 rounded-sm hover:text-primary hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/50',
onClick: () => onRowClick(row.id),
},
[row.id, h(FontAwesomeIcon, { icon: faChevronRight, class: 'size-3', 'aria-hidden': 'true' })],
)
}
const columns = computed<ColumnDef<DrilldownRow, unknown>[]>(() => [
{ id: 'id', header: idColumnLabel.value, accessorFn: (r) => r.id, cell: ({ row }) => idCell(row.original) },
...props.metrics.map(
(metric): ColumnDef<DrilldownRow, unknown> => ({
id: metric,
header: SPEND_METRIC_LABEL[metric as HistoryMetric] ?? metric,
meta: { align: 'right' },
accessorFn: (r) => r.values[metric] ?? 0,
cell: ({ row }) => {
const value = row.original.values[metric] ?? 0
return metric === 'cost' ? formatCost(value) : h(CompactNumber, { value })
},
}),
),
])
const showUserModelHint = computed<boolean>(() => level.value.kind === 'user' && (!props.userModelStatsEnabled || props.disabled))
</script>
<template>
<Card>
<CardHeader>
<CardTitle>Attribution</CardTitle>
<CardDescription>Drill from groups into users into the models they used.</CardDescription>
<nav aria-label="Breadcrumb" class="flex flex-wrap items-center gap-1.5 text-sm text-muted-foreground">
<button type="button" class="hover:text-foreground hover:underline" :disabled="level.kind === 'group'" @click="goToGroups">
All groups
</button>
<template v-if="level.kind === 'user'">
<FontAwesomeIcon :icon="faChevronRight" class="size-3" aria-hidden="true" />
<span class="font-medium text-foreground">{{ level.group }}</span>
</template>
<template v-if="level.kind === 'usermodel'">
<FontAwesomeIcon :icon="faChevronRight" class="size-3" aria-hidden="true" />
<button v-if="parentGroupLabel" type="button" class="hover:text-foreground hover:underline" @click="goToParentGroup">
{{ parentGroupLabel }}
</button>
<FontAwesomeIcon :icon="faChevronRight" class="size-3" aria-hidden="true" />
<span class="font-medium text-foreground">{{ level.user }}</span>
</template>
</nav>
</CardHeader>
<CardContent class="flex flex-col gap-3">
<ClampedRangeNotice v-if="level.kind === 'usermodel'" :window="window" :span="span" />
<p v-if="showUserModelHint" class="text-sm text-muted-foreground">
Per-model attribution for this user needs <code class="font-mono text-xs">admin.stats.userModel</code> enabled.
</p>
<DataTable
v-else
:columns="columns"
:data="rows"
:empty-message="level.kind === 'group' ? 'No group has spend in this range.' : 'Nothing in this range.'"
/>
</CardContent>
</Card>
</template>
+92
View File
@@ -0,0 +1,92 @@
<script setup lang="ts">
import { computed } from 'vue'
import TimeSeriesChart from '@/components/TimeSeriesChart.vue'
import type { TimeSeriesDataset } from '@/components/TimeSeriesChart.vue'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { cumulative, runOutDate } from '@/lib/burndown'
import { formatBucketLabel, formatCost } from '@/lib/format'
/**
* BurnDownChart (redesign-plan.md section 3.4) renders the current UTC
* calendar month's cumulative spend against its configured budget: a
* solid line (lib/burndown.ts's `cumulative`, run over the store's own raw
* per-day points — see stores/spend.ts's own doc comment on why the
* running-sum step lives here rather than in the store) plus, when a
* budget is configured, a dashed reference line at that flat limit
* (TimeSeriesChart's own `comparisonDatasets` — a budget ceiling is
* exactly the "reference overlay, not a component of the current total"
* that prop already documents) and the projected run-out date
* (lib/burndown.ts's runOutDate, MTD average daily rate).
*
* `now` is a required prop, not read from `new Date()` internally —
* CostForecastCard.vue's own doc comment explains why a component-local
* default silently freezes at first mount (Vue caches a prop default
* once per instance): the caller passes the SAME shared clock
* (composables/useNow.ts) every other time-dependent read on the page
* shares.
*/
const props = defineProps<{
/** Day-window bucket keys, oldest first (limits.go's "20060102" format — lib/format.ts's formatBucketLabel). */
buckets: string[]
/** Raw (not cumulative) per-day cost in micro-USD, index-aligned with `buckets`. */
points: number[]
/** The scope's configured cost/month budget in micro-USD, or null when none is configured. */
budgetMicros: number | null
now: Date
}>()
const labels = computed<string[]>(() => props.buckets.map((b) => formatBucketLabel(b, 'day')))
const cumulativePoints = computed<number[]>(() => cumulative(props.points))
const spentMicros = computed<number>(() => cumulativePoints.value.at(-1) ?? 0)
const datasets = computed<TimeSeriesDataset[]>(() => [{ label: 'Spent (MTD)', data: cumulativePoints.value, color: '--chart-cost' }])
const comparisonDatasets = computed<TimeSeriesDataset[]>(() => {
if (props.budgetMicros === null) return []
const flat = new Array<number>(cumulativePoints.value.length).fill(props.budgetMicros)
return [{ label: 'Budget', data: flat, color: '--destructive' }]
})
const runOut = computed<Date | null>(() => (props.budgetMicros === null ? null : runOutDate(spentMicros.value, props.budgetMicros, props.now)))
const willExceed = computed<boolean>(() => props.budgetMicros !== null && spentMicros.value >= props.budgetMicros)
</script>
<template>
<Card>
<CardHeader>
<CardTitle>Burn-down</CardTitle>
<CardDescription>Cumulative spend this UTC calendar month against the configured budget.</CardDescription>
</CardHeader>
<CardContent class="flex flex-col gap-4">
<div class="grid grid-cols-2 gap-4 text-sm sm:grid-cols-3">
<div>
<p class="text-muted-foreground">Spent so far</p>
<p class="text-lg font-semibold tabular-nums" :class="willExceed ? 'text-destructive' : undefined">
{{ formatCost(spentMicros) }}
</p>
</div>
<div v-if="budgetMicros !== null">
<p class="text-muted-foreground">Budget</p>
<p class="text-lg font-semibold tabular-nums">{{ formatCost(budgetMicros) }}</p>
</div>
<div v-if="budgetMicros !== null">
<p class="text-muted-foreground">Projected run-out</p>
<p v-if="runOut === null" class="text-sm text-muted-foreground">not enough data yet</p>
<p v-else class="text-lg font-semibold tabular-nums" :class="willExceed ? 'text-destructive' : undefined">
{{ runOut.toLocaleDateString(undefined, { month: 'short', day: 'numeric' }) }}
</p>
</div>
</div>
<p v-if="budgetMicros === null" class="text-sm text-muted-foreground">No budget configured for this scope.</p>
<TimeSeriesChart
:labels="labels"
:datasets="datasets"
:comparison-datasets="comparisonDatasets"
:value-formatter="formatCost"
ariaLabel="Cumulative spend this month against the configured budget"
/>
</CardContent>
</Card>
</template>
+63
View File
@@ -0,0 +1,63 @@
<script setup lang="ts">
import { computed } from 'vue'
import GrantForm from '@/components/forms/GrantForm.vue'
import LimitsForm from '@/components/forms/LimitsForm.vue'
import ModelMetaForm from '@/components/forms/ModelMetaForm.vue'
import PricingForm from '@/components/forms/PricingForm.vue'
import UserForm from '@/components/forms/UserForm.vue'
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs'
import { useNavStore } from '@/stores/nav'
/** HELPER_IDS mirrors the Config page's own `helper` hash param (redesign-plan.md section 3.1: `helper=limits|grant|pricing|model|user`) — the single source of truth for which tab is selected and which form renders. */
const HELPER_IDS = ['limits', 'grant', 'pricing', 'model', 'user'] as const
type HelperId = (typeof HELPER_IDS)[number]
const HELPER_LABEL: Record<HelperId, string> = {
limits: 'Limits',
grant: 'Access grant',
pricing: 'Pricing',
model: 'Model metadata',
user: 'New user',
}
/**
* ChangeHelper (redesign-plan.md section 3.4) is the Config page's
* snippet-building surface: five forms (LimitsForm, GrantForm,
* PricingForm, ModelMetaForm, UserForm), each producing a paste-ready
* YAML/users.json fragment via lib/snippets.ts, switched by the page's own
* `helper` hash param so a shared link opens the same tab.
*/
const props = defineProps<{
groupNames: string[]
userNames: string[]
}>()
const nav = useNavStore()
const helper = computed<HelperId>(() => {
const value = nav.params.helper
return (HELPER_IDS as readonly string[]).includes(value ?? '') ? (value as HelperId) : 'limits'
})
function setHelper(next: unknown): void {
if (typeof next !== 'string' || !(HELPER_IDS as readonly string[]).includes(next)) return
nav.goTo('config', { ...nav.params, helper: next })
}
</script>
<template>
<div class="flex flex-col gap-4">
<Tabs :model-value="helper" @update:model-value="setHelper">
<TabsList>
<TabsTrigger v-for="id in HELPER_IDS" :key="id" :value="id">{{ HELPER_LABEL[id] }}</TabsTrigger>
</TabsList>
</Tabs>
<LimitsForm v-if="helper === 'limits'" :group-names="props.groupNames" :user-names="props.userNames" />
<GrantForm v-else-if="helper === 'grant'" :group-names="props.groupNames" :user-names="props.userNames" />
<PricingForm v-else-if="helper === 'pricing'" />
<ModelMetaForm v-else-if="helper === 'model'" />
<UserForm v-else :group-names="props.groupNames" />
</div>
</template>
-281
View File
@@ -1,281 +0,0 @@
<script setup lang="ts">
import { faXmark } from '@fortawesome/free-solid-svg-icons'
import { computed, onMounted, onUnmounted, watch } from 'vue'
import SearchInput from '@/components/SearchInput.vue'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs'
import CsvExportButton from '@/components/CsvExportButton.vue'
import DataTable from '@/components/DataTable.vue'
import ModelUsageChart from '@/components/ModelUsageChart.vue'
import UsageChart from '@/components/UsageChart.vue'
import { useSearchQuery } from '@/composables/useSearchQuery'
import { filterModelsByPrefix } from '@/lib/model-filter'
import { modelTableColumns } from '@/lib/model-table-columns'
import { modelDetailCsv } from '@/lib/usage-csv'
import { groupMatches, userMatches } from '@/lib/usage-search'
import { useDashboardStore } from '@/stores/dashboard'
import {
type ChartTab,
MODEL_METRIC_LABEL,
MODEL_RANKING_LIMIT,
type ModelMetric,
type TimeSeriesTab,
useHistoryStore,
WINDOW_LABEL,
WINDOW_SPAN,
} from '@/stores/history'
import type { HistoryWindow } from '@/types/api'
const dashboard = useDashboardStore()
const history = useHistoryStore()
// --- user/group search filter (operator feature) ---
//
// Filters the scope picker's user/group list only — "total" always stays
// reachable (unconditionally the list's first entry, below), and the
// CURRENTLY SELECTED scope is always kept in the list even if it stops
// matching a new query: the filter narrows what's OFFERED, it must never
// force-switch (or hide the label of) whatever chart is already rendered.
// Same shared SearchInput/useSearchQuery and lib/usage-search.ts matching
// helpers as UsageView.vue — one implementation, no second copy.
const { query: scopeQuery, normalized: normalizedScopeQuery, hasQuery: hasScopeQuery } = useSearchQuery()
const scopeOptions = computed(() => {
const allUsers = dashboard.usage?.users ?? []
const allGroups = dashboard.usage?.groups ?? []
const users = allUsers
.filter((u) => !hasScopeQuery.value || userMatches(u, normalizedScopeQuery.value) || `user:${u.id}` === history.scope)
.map((u) => ({ value: `user:${u.id}`, label: u.id }))
const groups = allGroups
.filter(
(g) =>
!hasScopeQuery.value || groupMatches(g, allUsers, normalizedScopeQuery.value) || `group:${g.id}` === history.scope,
)
.map((g) => ({ value: `group:${g.id}`, label: g.id }))
// Models come from the ranking endpoint, which returns only models with
// non-zero traffic (operator requirement: the catalog is hundreds of
// models, so the picker must never list idle ones). Matched on the plain
// id rather than through lib/usage-search's helpers — those match a
// user's or group's own shape, and a model entry has neither.
const models = (history.modelOptions ?? [])
.filter(
(m) =>
!hasScopeQuery.value ||
m.id.toLowerCase().includes(normalizedScopeQuery.value) ||
`model:${m.id}` === history.scope,
)
.map((m) => ({ value: `model:${m.id}`, label: m.id }))
// A model already being charted stays in the list even once it drops out
// of modelOptions entirely — which happens the moment it goes idle for
// the selected window, since that list is non-zero-only. Without this,
// the watch below would read it as a vanished scope and force-switch the
// reader to "total" mid-look, for the ordinary reason that a model
// simply stopped receiving traffic.
if (history.scope.startsWith('model:') && !models.some((m) => m.value === history.scope)) {
models.push({ value: history.scope, label: history.scope.slice('model:'.length) })
}
return [{ value: 'total', label: 'Total (all traffic)' }, ...groups, ...users, ...models]
})
const windows: HistoryWindow[] = ['hour', 'day', 'month']
const modelMetrics: ModelMetric[] = ['cost', 'req', 'tokin', 'tokout']
/**
* modelColumns is a stateless ColumnDef set (lib/model-table-columns.ts),
* built once rather than per-render — the same convention
* TargetsView.vue's own `columns` constant follows for its identical
* reason (no per-row closures capture component state, so there is
* nothing to rebuild).
*/
const modelColumns = modelTableColumns()
// --- Models-tab prefix filter (F6, dashboard-plan.md) ---
//
// history.modelFilter is set by ProvidersView.vue's provider-header link
// (nav.goToModels(`${p.name}/`), stores/nav.ts — WP-B1) or restored from
// the `#charts?tab=models&filter=...` hash (lib/hash-state.ts). P3 review
// fix: setModelFilter now also sends the prefix to the SERVER and
// refetches (stores/history.ts's own state doc comment on modelFilter),
// so this re-slice is a harmless second pass over an already-narrowed
// ranking, not the only filtering step — see lib/model-filter.ts's own
// doc comment.
const filteredModelRanking = computed(() => filterModelsByPrefix(history.modelRanking, history.modelFilter))
/**
* Exports exactly the ranking rows on screen (filter applied, server order
* kept). free-models-plan.md: every ranking fetch now requests detail=1
* (stores/history.ts's fetchModelRanking), so the export carries every
* per-row figure (requests/tokens in/tokens out/cost/free) via
* modelDetailCsv, not just the single metric currently being ranked by —
* matching the on-screen table (lib/model-table-columns.ts) exactly,
* regardless of which metric history.modelMetric happens to be.
*/
function modelsCsv(): string {
return modelDetailCsv(filteredModelRanking.value, WINDOW_LABEL[history.window], WINDOW_SPAN[history.window])
}
function clearModelFilter(): void {
history.setModelFilter('')
}
/**
* UsageChart only ever renders on a time-series tab (the template's v-else
* below), but a `v-else` narrows nothing for the type checker — this does.
* The 'requests' stand-in is never displayed: it is the value handed over
* on the one tab where the component is not rendered at all.
*/
const timeSeriesTab = computed<TimeSeriesTab>(() => (history.tab === 'models' ? 'requests' : history.tab))
function onTabChange(value: string | number): void {
history.setTab(value as ChartTab)
}
function onScopeChange(value: unknown): void {
if (typeof value === 'string') history.setScope(value)
}
function onModelMetricChange(value: unknown): void {
if (typeof value === 'string') history.setModelMetric(value as ModelMetric)
}
onMounted(() => {
// The picker's model list is fetched here (initial load) and again on
// any window change (history.ts's own setWindow) — nowhere else (Q1,
// dashboard-plan.md DECISIONS). The selection's own series/ranking
// fetch no longer needs a separate call here: startAutoRefresh's poller
// (lib/polling.ts) ticks once immediately on start, which is that
// initial fetch.
void history.fetchModelOptions()
history.startAutoRefresh()
})
onUnmounted(() => {
history.stopAutoRefresh()
})
// The scope list is only known once the Usage response has loaded — if the
// previously-selected scope disappears (a user/group config change), fall
// back to the total scope rather than requesting a now-unknown id. This is
// ALSO what guards a bogus scope restored from a hand-edited URL hash (F9,
// dashboard-plan.md: lib/hash-state.ts's parseChartsParams only validates
// SHAPE — "total"/"user:.+"/"group:.+"/"model:.+" — never that the id
// actually exists): `watch` without `{ immediate: true }` skips its first
// evaluation, so a hash-restored scope like "user:ghost" survives the
// (likely still-empty, pre-fetch) initial scopeOptions and is only judged
// once dashboard.usage genuinely loads and this computed's value actually
// changes — at which point a truly nonexistent id resets to 'total' here,
// exactly like any other vanished scope.
watch(scopeOptions, (options) => {
if (!options.some((o) => o.value === history.scope)) {
history.setScope('total')
}
})
</script>
<template>
<Card>
<CardHeader class="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<CardTitle>Usage charts</CardTitle>
<div class="flex flex-wrap items-center gap-3">
<!-- The Models tab has no scope picker to filter (its own Select branch below is the ranking-metric picker instead) — a visible search box that silently does nothing is worse than no box at all. -->
<SearchInput
v-if="history.tab !== 'models'"
v-model="scopeQuery"
placeholder="Filter users, groups or models"
class="w-56"
/>
<Select
v-if="history.tab === 'models'"
:model-value="history.modelMetric"
@update:model-value="onModelMetricChange"
>
<SelectTrigger class="w-40" aria-label="Rank models by">
<SelectValue placeholder="Rank by" />
</SelectTrigger>
<SelectContent>
<SelectItem v-for="m in modelMetrics" :key="m" :value="m">
{{ MODEL_METRIC_LABEL[m] }}
</SelectItem>
</SelectContent>
</Select>
<Select v-else :model-value="history.scope" @update:model-value="onScopeChange">
<SelectTrigger class="w-56">
<SelectValue placeholder="Scope" />
</SelectTrigger>
<SelectContent>
<SelectItem v-for="opt in scopeOptions" :key="opt.value" :value="opt.value">
{{ opt.label }}
</SelectItem>
</SelectContent>
</Select>
<Tabs :model-value="history.window" @update:model-value="(v) => history.setWindow(v as HistoryWindow)">
<TabsList>
<TabsTrigger v-for="w in windows" :key="w" :value="w">{{ WINDOW_LABEL[w] }}</TabsTrigger>
</TabsList>
</Tabs>
</div>
</CardHeader>
<CardContent class="flex flex-col gap-4">
<Tabs :model-value="history.tab" @update:model-value="onTabChange">
<TabsList>
<TabsTrigger value="requests">Requests</TabsTrigger>
<TabsTrigger value="tokens">Tokens</TabsTrigger>
<TabsTrigger value="cost">Cost</TabsTrigger>
<TabsTrigger value="models">Models</TabsTrigger>
</TabsList>
</Tabs>
<p v-if="history.error" class="text-sm text-destructive">{{ history.error }}</p>
<!-- Only until the current selection's first fetch lands (history.loaded) — background refreshes never flash it over rendered data. -->
<p v-else-if="history.loading && !history.loaded" class="text-sm text-muted-foreground">Loading…</p>
<template v-if="history.tab === 'models'">
<p class="text-sm text-muted-foreground">
Models with usage over the last {{ WINDOW_LABEL[history.window] }}, ranked by
{{ MODEL_METRIC_LABEL[history.modelMetric].toLowerCase() }}. Each row is the provider that served the
traffic, so a model reached through more than one provider appears once per provider.
</p>
<p v-if="history.modelFilter" class="flex items-center gap-1.5 text-sm">
<span class="text-muted-foreground">Filtered to</span>
<span class="font-mono text-xs">{{ history.modelFilter }}</span>
<Button variant="ghost" size="icon-xs" aria-label="Clear model filter" @click="clearModelFilter">
<FontAwesomeIcon :icon="faXmark" class="size-3.5" aria-hidden="true" />
</Button>
</p>
<!-- P3: the ranking is a top-N (MODEL_RANKING_LIMIT), never the complete set — say so whenever the fetched ranking is exactly that long, so a full list doesn't silently read as "this is everything". -->
<p v-if="history.modelRanking.length === MODEL_RANKING_LIMIT" class="text-xs text-muted-foreground">
Top {{ MODEL_RANKING_LIMIT }} models
</p>
<ModelUsageChart :metric="history.modelMetric" :models="filteredModelRanking" />
<!--
free-models-plan.md: the per-model detail table beneath the
chart, with a per-row Requests/Tokens in/Tokens out/Cost
breakdown (a free model shows a "free" badge instead of $0 —
lib/model-table-columns.ts). Guarded by the same
filteredModelRanking.length check ModelUsageChart's own empty
state already uses above, so an empty window shows exactly ONE
"no model recorded any usage" message, not a second, empty
table stacked under it.
-->
<DataTable
v-if="filteredModelRanking.length"
:columns="modelColumns"
:data="filteredModelRanking"
empty-message="No model recorded any usage in this window."
/>
<div class="flex justify-end">
<CsvExportButton :filename="`usage-models-${history.window}.csv`" :build="modelsCsv" />
</div>
</template>
<UsageChart v-else :tab="timeSeriesTab" :window="history.window" :series-by-metric="history.seriesByMetric" />
</CardContent>
</Card>
</template>
@@ -0,0 +1,38 @@
<script setup lang="ts">
import { computed } from 'vue'
import { Alert, AlertDescription } from '@/components/ui/alert'
import { dayOrMonthClampNotice } from '@/lib/range'
import type { HistoryWindow } from '@/types/api'
/**
* ClampedRangeNotice (N3 fix, verify-redesign-final.md) is the ONE shared
* "this view silently clamped your range" banner for every day/month-only
* endpoint reached through lib/range.ts's dayOrMonthWindow:
* - UserDetail.vue's "Models used" table (usermodel totals)
* - AttributionDrilldown.vue's usermodel level (the Spend drilldown)
* - TargetCallers.vue (targetcaller totals)
* An hour-resolution global range (24h/48h) has no day/month equivalent
* for any of these — dayOrMonthWindow silently substitutes a fixed
* 7-day day-window instead, and this renders nothing (dayOrMonthClampNotice
* returns null) unless that substitution actually happened, so a
* day/month-window reader never sees a spurious banner.
*
* Distinct from ReliabilityPage.vue's own inline month-clamp Alert — a
* DIFFERENT clamp (hour/day -> a fixed 30-day window, stores/
* reliability.ts's hourOrDayWindow) that stays its own local computed,
* not this component.
*/
const props = defineProps<{
window: HistoryWindow
span: number
}>()
const note = computed<string | null>(() => dayOrMonthClampNotice(props.window, props.span))
</script>
<template>
<Alert v-if="note" variant="warn">
<AlertDescription>{{ note }}</AlertDescription>
</Alert>
</template>
+2 -2
View File
@@ -9,8 +9,8 @@ import { formatCompactCount, formatExactInt } from '@/lib/format'
* (mouse hover) and a visually-hidden sibling span (keyboard/screen-
* reader parity). Used anywhere a usage count renders: DataTable numeric
* columns (usage-columns.ts, target-columns.ts, via
* `h(CompactNumber, { value })`), the Usage tab's Total card, and a
* group accordion's trigger/detail stat grid (UsageView.vue).
* `h(CompactNumber, { value })`), the Consumers page's Total row, and a
* group accordion's trigger/detail stat grid (ConsumerDirectory.vue).
*
* Review fix (folded minor, ARIA): `aria-label` on a plain `<span>` is
* an axe `aria-prohibited-attr` violation — a `<span>`'s implicit ARIA
+37
View File
@@ -0,0 +1,37 @@
<script setup lang="ts">
import { computed } from 'vue'
import SnippetBlock from '@/components/SnippetBlock.vue'
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
import { emitYamlDocument, toYamlValue } from '@/lib/yaml-emit'
import type { YamlValue } from '@/lib/yaml-emit'
/**
* ConfigTree (redesign-plan.md section 3.4) renders GET /admin/api/config's
* already-redacted `config` (admin_config.go's redactConfig — every
* apiKey/password literal replaced with "[redacted]", an "env:NAME"/
* "file:/path" indirection kept, every baseUrl/url stripped of userinfo)
* as read-only YAML via lib/yaml-emit.ts's generic recursive renderer —
* safe to render as-is, never re-sanitized client-side (types/api.ts's
* AdminConfigResponse doc comment).
*/
const props = defineProps<{
config: Record<string, unknown>
warnings: string[]
}>()
const yamlText = computed(() => {
const value = toYamlValue(props.config) as Record<string, YamlValue | undefined>
return emitYamlDocument(value)
})
</script>
<template>
<div class="flex flex-col gap-4">
<Alert v-for="(warning, index) in warnings" :key="index" variant="warn">
<AlertTitle>Config warning</AlertTitle>
<AlertDescription>{{ warning }}</AlertDescription>
</Alert>
<SnippetBlock label="Redacted running config (read-only)" :text="yamlText" />
</div>
</template>
@@ -2,13 +2,11 @@
import type { SortingState } from '@tanstack/vue-table'
import { getCoreRowModel, getSortedRowModel, useVueTable } from '@tanstack/vue-table'
import { computed, onMounted, onUnmounted, reactive, ref, watch } from 'vue'
import { storeToRefs } from 'pinia'
import CompactNumber from '@/components/CompactNumber.vue'
import CostForecastCard from '@/components/CostForecastCard.vue'
import CsvExportButton from '@/components/CsvExportButton.vue'
import ModelChip from '@/components/ModelChip.vue'
import ScopeLink from '@/components/ScopeLink.vue'
import SearchInput from '@/components/SearchInput.vue'
import SortHeaderButton from '@/components/SortHeaderButton.vue'
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from '@/components/ui/accordion'
@@ -19,7 +17,6 @@ import UsageBar from '@/components/UsageBar.vue'
import UsageTable from '@/components/UsageTable.vue'
import { useNow } from '@/composables/useNow'
import { useSearchQuery } from '@/composables/useSearchQuery'
import { useNavStore } from '@/stores/nav'
import { headroom, monthProgress, projectMonthEnd } from '@/lib/forecast'
import { formatCost, formatExactInt, formatLimits } from '@/lib/format'
import { type ExpandState, clearExpandOverrides, computeExpandedItems, toggleItemExpand } from '@/lib/search-expand'
@@ -27,19 +24,37 @@ import { BUDGET_RATIO_LABEL, type BudgetRatio, budgetRatios } from '@/lib/usage-
import { usageColumns } from '@/lib/usage-columns'
import { usageCsv } from '@/lib/usage-csv'
import { groupMatches, groupNameMatches, matchingMembersOfGroup, membersOfGroup, userMatches } from '@/lib/usage-search'
import { useConsumersStore } from '@/stores/consumers'
import { useDashboardStore } from '@/stores/dashboard'
import { useNavStore } from '@/stores/nav'
import type { AdminUsageEntryView } from '@/types/api'
/**
* ConsumerDirectory (redesign-plan.md section 3.4) is the Consumers page's
* default view: the Users/Groups usage-and-access directory that used to
* be UsageView.vue's whole tab, moved here verbatim (the Groups accordion
* + its access block included, per the coordinator's own instruction) with
* three redesign-era changes: nav.usageQuery -> the page's own `q` hash
* param (stores/nav.ts no longer special-cases a search string), the id
* cell's ScopeLink -> EntityLink (usage-columns.ts, opens this row's own
* UserDetail instead of jumping to the old flat Charts tab), and a new
* Source column (inline/file, GET /admin/api/consumers) on the flat Users
* table only.
*/
const dashboard = useDashboardStore()
const consumers = useConsumersStore()
const nav = useNavStore()
const usage = computed(() => dashboard.usage)
// The one shared reactive clock every time-dependent read on this tab uses
onMounted(() => {
void consumers.ensureConsumers()
})
// The one shared reactive clock every time-dependent read on this page uses
// (P1, composables/useNow.ts's own doc comment) — CostForecastCard's own
// elapsedFraction, usageColumns()'s `now` parameter (both the real Users/
// Members <table>s below and the headless Groups toolbar's groupColumns),
// and groupCostProjection all read THIS ref, never their own `new Date()`,
// so a long-open tab's cost projections advance together instead of
// freezing at whatever instant each one happened to first run.
// and groupCostProjection all read THIS ref, never their own `new Date()`.
const { now, start: startClock, stop: stopClock } = useNow()
onMounted(startClock)
onUnmounted(stopClock)
@@ -60,25 +75,24 @@ function memberCountOf(entry: AdminUsageEntryView): string {
return count === undefined ? '' : String(count)
}
// --- user/group search filter (operator feature, mirrors
// ProvidersView.vue's model search via the shared SearchInput component and
// useSearchQuery composable — same styling, same clear-button behavior.
// Matching logic lives in lib/usage-search.ts, which ChartsView.vue's own
// scope-picker filter calls directly too (groupMatches, userMatches) — no
// second copy of "does this user/group match?") ---
// Bound to nav.usageQuery so the search text round-trips through the URL hash (#usage?q=…, lib/hash-state.ts).
const { usageQuery } = storeToRefs(useNavStore())
/** sourceOf (Consumers redesign) joins a usage entry's id against GET /admin/api/consumers' own users list — 'inline' or 'file' (admin.go: adminConsumerUser.Source), undefined until that on-demand fetch resolves or for an id it has no match for (a group row never has one). Passed to usage-columns.ts's usageColumns() as `sourceValue` — see that module's sourceColumn doc comment. */
function sourceOf(entry: AdminUsageEntryView): string | undefined {
return consumers.data?.users.find((u) => u.name === entry.id)?.source
}
// --- user/group search filter (operator feature) ---
// Matching logic lives in lib/usage-search.ts. Bound to nav.params.q so
// the search text round-trips through the URL hash (#consumers?q=...,
// lib/hash-state.ts) — stores/nav.ts's own `params` is opaque, so this
// page owns interpreting/writing its own `q` key directly, rather than
// the old nav.usageQuery special case.
const usageQuery = computed<string>({
get: () => nav.params.q ?? '',
set: (value) => nav.goTo('consumers', { ...nav.params, q: value }),
})
const { query: userQuery, normalized: normalizedQuery, hasQuery } = useSearchQuery(usageQuery)
/**
* filteredGroups is every group matching the query — own name OR a
* matching member (lib/usage-search.ts's groupMatches, binding semantics)
* — or every group when there is no query. This feeds groupsTable's
* `data()` below directly, so filtering happens upstream of sorting: the
* Groups sort toolbar and the Accordion both operate on the same
* (possibly narrowed) list, no separate filtering pass downstream of the
* table.
*/
/** filteredGroups is every group matching the query — own name OR a matching member (lib/usage-search.ts's groupMatches) — or every group when there is no query. */
const filteredGroups = computed<AdminUsageEntryView[]>(() => {
const all = usage.value?.groups ?? []
if (!hasQuery.value) return all
@@ -86,101 +100,46 @@ const filteredGroups = computed<AdminUsageEntryView[]>(() => {
return all.filter((g) => groupMatches(g, users, normalizedQuery.value))
})
/**
* memberOnlyMatchIds is filteredGroups narrowed to groups that matched
* ONLY via a member, never via their own name — the auto-expand target
* set (search-expand.ts's computeExpandedItems `matchingIds`).
*
* This is a DELIBERATE divergence from ProvidersView.vue, not an
* oversight, and NOT because Providers lacks a provider-name match —
* it doesn't lack one: providerMatches there (ProvidersView.vue) checks
* each model's full ROUTABLE id, `${provider}/${model}`
* (lib/format.ts's routableModelId), so a query like "openai" matches
* every "openai/..." model id and the whole provider auto-expands with
* every one of them showing. That is correct there — a provider's own
* model list IS what the search is for, so revealing it is the point,
* and EVERY provider match (name-flavored or not) auto-expands.
*
* Groups differ: a group's own name is already fully legible on its
* COLLAPSED trigger row (the `<AccordionTrigger>` below) — no expansion
* needed to read a name match. Auto-expanding every name-matched group too
* would dump every one of their full member rosters onto the screen for a
* query that only needed the trigger row to already be visible. A member-ONLY
* match has no such shortcut — the member that matched stays invisible
* until the group opens — so that case alone still auto-expands.
* search-expand.ts itself has no opinion on any of this; it is purely
* this view's own choice of matchingIds.
*/
/** memberOnlyMatchIds is filteredGroups narrowed to groups that matched ONLY via a member, never via their own name — the auto-expand target set (search-expand.ts's computeExpandedItems `matchingIds`). A group's own name is already legible on its collapsed trigger row, so only a member-only match needs auto-expansion to reveal what matched. */
const memberOnlyMatchIds = computed<string[]>(() => {
if (!hasQuery.value) return []
return filteredGroups.value.filter((g) => !groupNameMatches(g, normalizedQuery.value)).map((g) => g.id)
})
/**
* visibleMembers implements the members-table distinction (binding
* semantics): a group matched by its OWN name shows every member
* (membersOfGroup); a group that matched only via a member shows only
* the matching ones — the exported, tested matchingMembersOfGroup
* helper (lib/usage-search.ts), not a re-filter of userMatches here. No
* active query behaves like a name match — show everyone, today's
* behavior unchanged. Replaces the former local membersOf helper.
*/
/** visibleMembers: a group matched by its OWN name shows every member; a group that matched only via a member shows only the matching ones. */
function visibleMembers(group: AdminUsageEntryView): AdminUsageEntryView[] {
const users = usage.value?.users ?? []
if (!hasQuery.value || groupNameMatches(group, normalizedQuery.value)) return membersOfGroup(group, users)
return matchingMembersOfGroup(group, users, normalizedQuery.value)
}
/** groupsEmptyMessage mirrors ProvidersView.vue's aliasEmptyMessage three-way pattern: distinguishes nothing configured from nothing matching the active query, so an empty Groups card never reads the same regardless of why it's empty. */
const groupsEmptyMessage = computed(() =>
!usage.value?.groups.length ? 'none configured' : hasQuery.value ? `no groups match "${userQuery.value}"` : 'none',
)
/**
* filteredUsers is every user matching the query directly, PLUS every
* member of a group that matched by NAME (semantics item 5: "a
* group-name match includes all of that group's member rows"). A group
* that matched only via ONE member must not pull its other, non-matching
* members into this flat table too — that narrower distinction is
* visibleMembers' job, scoped to the nested table inside an expanded
* group, not this one.
*/
/** filteredUsers is every user matching the query directly, PLUS every member of a group that matched by NAME. */
const filteredUsers = computed<AdminUsageEntryView[]>(() => {
const all = usage.value?.users ?? []
if (!hasQuery.value) return all
const groups = usage.value?.groups ?? []
const nameMatchedGroupIds = new Set(
groups.filter((g) => groupNameMatches(g, normalizedQuery.value)).map((g) => g.id),
)
const nameMatchedGroupIds = new Set(groups.filter((g) => groupNameMatches(g, normalizedQuery.value)).map((g) => g.id))
return all.filter(
(u) =>
userMatches(u, normalizedQuery.value) ||
(u.groups ?? [u.groupName]).some((g) => g !== undefined && nameMatchedGroupIds.has(g)),
(u) => userMatches(u, normalizedQuery.value) || (u.groups ?? [u.groupName]).some((g) => g !== undefined && nameMatchedGroupIds.has(g)),
)
})
/** usersEmptyMessage mirrors groupsEmptyMessage's three-way pattern (see its own doc comment). */
const usersEmptyMessage = computed(() =>
!usage.value?.users.length ? 'none configured' : hasQuery.value ? `no users match "${userQuery.value}"` : 'none',
)
// --- Groups accordion (operator directive) ---
// --- Groups accordion ---
//
// Groups render as a shadcn-vue Accordion, same component and rationale
// as Providers (ProvidersView.vue) — expanding a group shows its member
// users' own usage rows below it. A group's row cannot ALSO be a literal
// <table> row for the identical reason Providers' old expandable row was
// retired: AccordionItem wraps its trigger+content in a <div>, illegal
// directly inside a <tbody>/<tr>.
//
// "Sortable groups usage" (operator directive) is therefore implemented
// headlessly: the SAME usageColumns() defs used by the real <table>-based
// UsageTable (Users, and each group's own nested member list) drive a
// second, unrendered useVueTable instance here purely for its
// getSortedRowModel() — sortedGroupRows below. The small button toolbar
// under the Groups heading toggles that sort using the identical
// SortHeaderButton DataTable.vue's real <th>s use (same icons, same
// toggleSorting call, same accessible name), just laid out as a toolbar
// instead of <th>s, because there is no <table> here to put a <th> in.
// Groups render as a shadcn-vue Accordion — expanding a group shows its
// member users' own usage rows below it. "Sortable groups usage" is
// implemented headlessly: the SAME usageColumns() defs the real <table>-
// based UsageTable uses drive a second, unrendered useVueTable instance
// here purely for its getSortedRowModel() — sortedGroupRows below. The
// small button toolbar under the Groups heading toggles that sort using
// the identical SortHeaderButton DataTable.vue's real <th>s use.
const groupColumns = computed(() => usageColumns('Name', 'Members', memberCountOf, now.value))
const groupSorting = ref<SortingState>([])
const groupsTable = useVueTable({
@@ -201,36 +160,16 @@ const groupsTable = useVueTable({
})
const sortedGroupRows = computed(() => groupsTable.getRowModel().rows)
/**
* TOOLBAR_COLUMN_IDS restricts the toolbar to exactly the fields each
* trigger row displays (Name, Members, req/day, cost/day) — review fix:
* exposing all 11 usageColumns() fields as sortable when only 4 are
* visible per row let the accordion's order silently diverge from what
* the toolbar visibly claims to control. The full stat set stays
* available inside each expanded item's own detail grid, just not as a
* sort key here.
*/
/** TOOLBAR_COLUMN_IDS restricts the toolbar to exactly the fields each trigger row displays (Name, Members, req/day, cost/day) — the full stat set stays available inside each expanded item's own detail grid, just not as a sort key here. */
const TOOLBAR_COLUMN_IDS = new Set(['id', 'secondary', 'reqDay', 'costDay'])
const toolbarHeaders = computed(() =>
groupsTable.getHeaderGroups()[0].headers.filter((header) => TOOLBAR_COLUMN_IDS.has(header.column.id)),
)
const toolbarHeaders = computed(() => groupsTable.getHeaderGroups()[0].headers.filter((header) => TOOLBAR_COLUMN_IDS.has(header.column.id)))
/**
* Which groups are open — driven by the SAME tested lib/search-expand.ts
* state ProvidersView.vue uses for providers (generalized from
* provider-expand.ts specifically so this view could reuse it, rather
* than fork a second copy — see that module's own doc comment). The
* adapter shape is the identical get/set computed pattern
* expandedProviderValues uses there, translating the Set-based effective
* state into the string[] Accordion's `type="multiple"` v-model expects.
*/
/** Which groups are open — driven by the shared tested lib/search-expand.ts state. */
const groupExpandState: ExpandState = reactive({
manuallyExpanded: new Set<string>(),
manuallyCollapsed: new Set<string>(),
})
const expandedGroups = computed<Set<string>>(() =>
computeExpandedItems(groupExpandState, hasQuery.value, memberOnlyMatchIds.value),
)
const expandedGroups = computed<Set<string>>(() => computeExpandedItems(groupExpandState, hasQuery.value, memberOnlyMatchIds.value))
function toggleGroup(id: string): void {
toggleItemExpand(groupExpandState, id, expandedGroups.value.has(id))
}
@@ -249,74 +188,26 @@ const expandedGroupValues = computed<string[]>({
// A stale manuallyCollapsed suppression from one search must never
// silently carry into a later, unrelated one — see ExpandState's own doc
// comment. Watching userQuery (not just the clear button) covers
// backspacing to empty too — same convention as ProvidersView.vue.
// comment.
watch(userQuery, (value) => {
if (value.trim() === '') clearExpandOverrides(groupExpandState)
})
// --- Access block (group-access-display task, review round) ---
//
// GLOB_HINT is the title attribute on every Access label (Providers,
// Models, MCP servers, Agents): group.allowsX (auth.go) matches every one
// of these four configured lists via matchesGlob/path.Match semantics, not
// literal equality — a heading that just says "Models" leaves that
// invisible. One constant, not four copies of the same string (vue.md:
// "if you've written it twice, you owe an abstraction").
// --- Access block (moved from UsageView.vue, unchanged) ---
const GLOB_HINT = 'Glob pattern — matches like a shell wildcard (*, ?, [...])'
/**
* isGlobPattern reports whether one configured models entry is a glob
* pattern rather than a literal model id — the same special characters
* path.Match recognizes (auth.go's matchesGlob, which group.allowsModel
* calls). Only Models needs this distinction rendered differently: a
* literal id gets the copyable ModelChip treatment (it IS a routable id a
* caller can paste into a request's `model` field); a pattern like
* "gpt-4*" is not a valid id and must not invite copying it as one, so it
* renders as a plain, non-copyable Badge instead (see the template below).
* Providers/MCPServers/Agents render every entry as a plain Badge either
* way, so they need no such split.
*/
/** isGlobPattern reports whether one configured models entry is a glob pattern rather than a literal model id — the same special characters path.Match recognizes (auth.go's matchesGlob). */
function isGlobPattern(entry: string): boolean {
return /[*?[]/.test(entry)
}
// --- F1 usage-vs-limit bars (group detail grid) ---
//
// usage-columns.ts's own numericColumn already renders a bar beside each
// TABLE cell that has a configured limit (Users table, and — reused — the
// Members table inside an expanded group). A group's own accordion detail
// grid (the dl/dd block below, distinct from its nested Members table) has
// no per-cell numeric columns at all, so it renders budgetRatios directly
// as one compact row of labeled bars instead — the coordinator brief's own
// phrasing ("Group detail grid ... renders bars from budgetRatios").
// BUDGET_RATIO_LABEL (P10/P11 review fix): imported from lib/usage-bars.ts
// — this used to be a second, independently-declared copy here (BUDGET_LABEL)
// with slightly different text ("tok/day" here vs usage-columns.ts's own
// bar label), the exact DRY drift the coordinator's review flagged. The one
// shared definition also spells out "tokens (in+out)/day" (P10) for the
// tokDay/tokMonth ids — accurate for a screen-reader user, who has no OTHER
// column beside this compact grid's own labeled bar to infer the combined-
// total measurement from.
/** budgetValueText renders one BudgetRatio's "used / limit" detail text (UsageBar's aria-valuetext/title) — cost ids in dollars (formatCost, the micro-USD unit budgetRatios already computed them in), every other id as a plain thousands-separated count. */
/** budgetValueText renders one BudgetRatio's "used / limit" detail text (UsageBar's aria-valuetext/title). */
function budgetValueText(ratio: BudgetRatio): string {
if (ratio.id.startsWith('cost')) return `${formatCost(ratio.used)} / ${formatCost(ratio.limit)}`
return `${formatExactInt(ratio.used)} / ${formatExactInt(ratio.limit)}`
}
/**
* groupCostProjection (F7) mirrors usage-columns.ts's own costMonthProjColumn
* math for one group row — the coordinator brief's own phrasing ("also in
* group grid"): the group detail dl/dd block is not a DataTable column, so
* it cannot reuse that column's ColumnDef cell renderer directly, but the
* underlying lib/forecast.ts math (monthProgress/projectMonthEnd/headroom)
* is identical. `now` is the shared clock (useNow, above) — the SAME
* instant every other time-dependent read on this tab uses — not a fresh
* `new Date()` read per call, so this grid's own projection never disagrees
* with CostForecastCard's or a column's mid-render.
*/
/** groupCostProjection mirrors usage-columns.ts's own costMonthProjColumn math for one group row. */
function groupCostProjection(entry: AdminUsageEntryView, now: Date): { text: string; willExceed: boolean } {
const elapsedFraction = monthProgress(now)
const projected = projectMonthEnd(entry.costPerMonthMicroUsd, elapsedFraction)
@@ -324,22 +215,8 @@ function groupCostProjection(entry: AdminUsageEntryView, now: Date): { text: str
return { text: formatCost(projected), willExceed: headroom(projected, entry.limits?.costPerMonthUSD).willExceed }
}
// --- F8 CSV export ---
//
// Each button exports whatever rows are CURRENTLY visible: Groups export
// in the Groups toolbar's own sort order (sortedGroupRows, already
// computed below for the headless sort toolbar); Users export in the
// Users <table>'s own CURRENT column sort (P6 review fix — this used to
// export in filtered order alone, a self-declared deviation from the plan's
// "exports filtered+sorted rows"). The Users DataTable's sort state is
// LIFTED UP here via DataTable.vue's `v-model:sorting` (that component's
// own doc comment) — usersSorting below is bound two ways: down into the
// rendered UsageTable (template) so a header click writes back here, and
// into a second, headless useVueTable instance (usersTable) that applies
// the identical sort to filteredUsers so usersCsv can read it back in the
// same order the reader is actually looking at — the same "one sort state,
// two consumers" shape sortedGroupRows/groupsTable already used for Groups.
const usersColumns = computed(() => usageColumns('Name', 'Group', groupNameOf, now.value))
// --- CSV export ---
const usersColumns = computed(() => usageColumns('Name', 'Group', groupNameOf, now.value, sourceOf))
const usersSorting = ref<SortingState>([])
const usersTable = useVueTable({
get data() {
@@ -391,7 +268,6 @@ function groupsCsv(): string {
<p class="text-lg font-semibold tabular-nums"><CompactNumber :value="usage.total.requestsPerDay" /></p>
</div>
<div>
<!-- F4: fleet-wide requests denied by a limit check today (types/api.ts's own doc comment) — destructive tint once positive, mirroring usage-columns.ts's own rejDay column convention. -->
<p class="text-muted-foreground">Rejected/day</p>
<p class="text-lg font-semibold tabular-nums" :class="usage.total.rejectionsPerDay > 0 ? 'text-destructive' : undefined">
<CompactNumber :value="usage.total.rejectionsPerDay" />
@@ -412,7 +288,6 @@ function groupsCsv(): string {
</CardContent>
</Card>
<!-- F7: fleet-wide cost forecast, directly under the Total card (coordinator brief). -->
<CostForecastCard v-if="usage" :mtd-micros="usage.total.costPerMonthMicroUsd" :limits="usage.total.limits" :now="now" />
<Card>
@@ -422,39 +297,18 @@ function groupsCsv(): string {
<CardTitle>Groups</CardTitle>
<CardDescription>Expand a group to see its member users' own usage.</CardDescription>
</div>
<CsvExportButton filename="usage-groups.csv" :build="groupsCsv" />
<CsvExportButton filename="consumers-groups.csv" :build="groupsCsv" />
</div>
</CardHeader>
<CardContent>
<div
v-if="sortedGroupRows.length"
class="mb-3 flex flex-wrap gap-x-4 gap-y-1 text-xs font-medium text-muted-foreground"
>
<div v-if="sortedGroupRows.length" class="mb-3 flex flex-wrap gap-x-4 gap-y-1 text-xs font-medium text-muted-foreground">
<SortHeaderButton v-for="header in toolbarHeaders" :key="header.id" :header="header" />
</div>
<Accordion v-if="sortedGroupRows.length" v-model="expandedGroupValues" type="multiple" class="rounded-md border px-3">
<AccordionItem v-for="row in sortedGroupRows" :key="row.original.id" :value="row.original.id">
<!--
P2 review fix: ScopeLink moved OUTSIDE AccordionTrigger — a
real interactive element can never be a valid descendant of
another interactive element (AccordionTrigger's own <button>,
reka-ui), so it is now a sibling in this header row instead
of nested inside the trigger's own slot. `py-2.5` matches
AccordionTrigger.vue's own vertical padding so the two line
up, keeping the same visual row the nested version had.
`header-class="flex-1"` fills the AccordionHeader (<h3>) that
wraps the trigger button, since AccordionTrigger's own
`class` prop lands on the inner button, not the header — see
AccordionTrigger.vue's doc comment. Without it the header
shrinks to content width, so the chevron drifts left of the
row's right edge and the empty space to its right stops
being clickable. The sr-only span gives the toggle button an
accessible name that names the group, since the visible text
beside it is just counts.
-->
<div class="flex items-center gap-2">
<ScopeLink class="shrink-0 py-2.5 font-medium" :label="row.original.id" :scope="`group:${row.original.id}`" />
<span class="shrink-0 py-2.5 font-medium">{{ row.original.id }}</span>
<AccordionTrigger header-class="flex-1">
<span class="sr-only">Group {{ row.original.id }} details</span>
<span class="flex flex-1 flex-wrap items-center gap-x-4 gap-y-1 pr-2 text-left">
@@ -470,16 +324,6 @@ function groupsCsv(): string {
</AccordionTrigger>
</div>
<AccordionContent>
<!--
Access block (group-access-display task): the group's
configured provider/model/MCP-server/agent access,
display-only — not part of the user/group search filter
above (row.original.providers/models/mcpServers/agents are
plain display data, never touched by groupMatches/
userMatches in lib/usage-search.ts). Same dl/dt/dd
convention as the stat grid immediately below — one
convention per panel.
-->
<dl class="mb-3 grid grid-cols-2 gap-x-6 gap-y-1.5 text-sm sm:grid-cols-4">
<div>
<dt class="text-xs text-muted-foreground" :title="GLOB_HINT">Providers</dt>
@@ -531,13 +375,7 @@ function groupsCsv(): string {
<dt class="text-xs text-muted-foreground" :title="GLOB_HINT">Agents</dt>
<dd>
<div v-if="row.original.agents?.length" class="flex flex-wrap gap-1.5">
<Badge
v-for="agentName in row.original.agents"
:key="agentName"
as="span"
variant="secondary"
class="font-mono font-normal"
>
<Badge v-for="agentName in row.original.agents" :key="agentName" as="span" variant="secondary" class="font-mono font-normal">
{{ agentName }}
</Badge>
</div>
@@ -597,7 +435,6 @@ function groupsCsv(): string {
<dd class="tabular-nums">{{ row.original.storeDown ? '?' : formatCost(row.original.costPerMonthMicroUsd) }}</dd>
</div>
<div>
<!-- F7: projected month-end cost, extrapolated from this group's own month-to-date spend (mirrors usage-columns.ts's costMonthProj column). -->
<dt class="text-xs text-muted-foreground">cost/month (proj.)</dt>
<dd
class="tabular-nums"
@@ -608,7 +445,6 @@ function groupsCsv(): string {
</dd>
</div>
</dl>
<!-- F1: usage-vs-limit bars, one per configured limit — budgetRatios (lib/usage-bars.ts) returns nothing for an unlimited group or a storeDown one, so this whole row is simply absent then. -->
<div v-if="budgetRatios(row.original).length" class="mb-3 flex flex-wrap items-center gap-x-4 gap-y-1.5">
<span
v-for="ratio in budgetRatios(row.original)"
@@ -626,6 +462,7 @@ function groupsCsv(): string {
:entries="visibleMembers(row.original)"
:secondary-value="groupNameOf"
:now="now"
:source-value="sourceOf"
/>
</AccordionContent>
</AccordionItem>
@@ -637,8 +474,11 @@ function groupsCsv(): string {
<Card>
<CardHeader>
<div class="flex items-start justify-between gap-2">
<CardTitle>Users</CardTitle>
<CsvExportButton filename="usage-users.csv" :build="usersCsv" />
<div>
<CardTitle>Users</CardTitle>
<CardDescription v-if="consumers.data?.usersFile">File-backed users load from {{ consumers.data.usersFile }}.</CardDescription>
</div>
<CsvExportButton filename="consumers-users.csv" :build="usersCsv" />
</div>
</CardHeader>
<CardContent>
@@ -649,6 +489,7 @@ function groupsCsv(): string {
:secondary-value="groupNameOf"
:empty-message="usersEmptyMessage"
:now="now"
:source-value="sourceOf"
v-model:sorting="usersSorting"
/>
</CardContent>
+142
View File
@@ -0,0 +1,142 @@
<script setup lang="ts">
import { computed } from 'vue'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { costAvoidedMicros, freeRows, pickReferenceModel } from '@/lib/cost-avoided'
import { formatCost } from '@/lib/format'
import type { AdminCatalogModel, AdminUsageModelEntry } from '@/types/api'
/**
* CostAvoidedCard (redesign-plan.md section 3.4) reports what the fleet's
* free-tier traffic in range WOULD have cost against a real, priced
* reference model — lib/cost-avoided.ts's own doc comment has the unit
* derivation. The reference model defaults to the most-requested priced
* model actually served in range (pickReferenceModel) but is always
* reader-overridable via the picker below; `ref` is a CONTROLLED prop
* (this card owns no picker state itself), mirroring DataTable.vue's own
* `sorting`/`update:sorting` convention — SpendPage.vue keeps the current
* choice in the `ref` page param (redesign-plan.md section 3.1) so a
* reload or shared link restores the same comparison.
*
* Cache savings (redesign-plan.md section 3.4: "cache savings (sum csave)
* shown alongside when enabled") render as a second figure beside the
* cost-avoided one, only when `cacheStatsEnabled` — a genuinely different
* saving (a repeat request served from cache, never re-billed at all) that
* would otherwise be easy to conflate with "served by a free model".
*
* N2 fix (verify-redesign-final.md): the figure is not always summable.
* `cacheSavingsAvailable` (lib/cache-savings.ts's own doc comment has the
* full backend grounding) is false for an hour-window range — the
* backend never writes a per-hour cache-savings bucket at all, so
* `m.cacheSavedMicroUsd` is undefined on every row and summing it would
* silently render a fabricated "$0.00" rather than "no data for this
* range". The card renders "n/a" instead whenever it is false.
*/
const props = defineProps<{
/** The model ranking (GET /admin/api/usage/models, detail=1) — every free row feeds costAvoidedMicros; every catalog-priced row is a candidate reference model. */
models: AdminUsageModelEntry[]
catalog: Map<string, AdminCatalogModel>
/**
* The reader's explicit reference-model override (the `ref` page param),
* or '' for "use the default". Named `refModel`, not `ref` — `ref` is a
* reserved Vue template attribute (component template refs); a prop
* literally named `ref` can never be set from a parent's template via
* `:ref="..."`, since Vue always intercepts that binding for its own
* template-ref mechanism instead of passing it through as a prop.
*/
refModel: string
/** AdminFeaturesView.cacheStats — gates the cache-savings figure. */
cacheStatsEnabled: boolean
/**
* cacheSavingsAvailable (N2 fix, lib/cache-savings.ts's own
* cacheSavingsStatus) — false for an hour-window range, where the
* backend has no per-hour cache-savings bucket at all. The card
* renders "n/a" instead of a figure when this is false, never a
* fabricated "$0.00". SpendPage.vue computes this from the current
* global range (it owns the filters store; this card stays a pure,
* props-driven component).
*/
cacheSavingsAvailable: boolean
/**
* cacheSavingsNote (N2 fix) — a caveat to show ALONGSIDE an available
* figure (currently only the month-window "35-day retention" note,
* lib/cache-savings.ts), or the reason text when
* `cacheSavingsAvailable` is false. Null when the figure is available
* and exact (day-window ranges).
*/
cacheSavingsNote?: string | null
}>()
const emit = defineEmits<{ 'update:refModel': [value: string] }>()
/** priceableOptions is the picker's own list: every served model whose catalog entry is genuinely priced (excludes free/unpriced — comparing free traffic against another $0 model is meaningless, the same filter pickReferenceModel itself applies), sorted by descending requests so the most relevant choices sit at the top. */
const priceableOptions = computed<{ id: string; requests: number }[]>(() => {
const out: { id: string; requests: number }[] = []
for (const m of props.models) {
const cat = props.catalog.get(m.id)
if (!cat || cat.priceSource === 'free' || cat.priceSource === 'unpriced') continue
out.push({ id: m.id, requests: m.requests ?? 0 })
}
return out.sort((a, b) => b.requests - a.requests)
})
const defaultReference = computed<string | null>(() => pickReferenceModel(props.models, props.catalog))
const effectiveReference = computed<string | null>(() => props.refModel || defaultReference.value)
const referenceCatalog = computed<AdminCatalogModel | undefined>(() =>
effectiveReference.value ? props.catalog.get(effectiveReference.value) : undefined,
)
const avoidedMicros = computed<number | null>(() => {
const cat = referenceCatalog.value
if (!cat || cat.inputPerMTokUsd === undefined || cat.outputPerMTokUsd === undefined) return null
return costAvoidedMicros(freeRows(props.models), cat.inputPerMTokUsd, cat.outputPerMTokUsd)
})
const cacheSavedMicros = computed<number>(() => {
let total = 0
for (const m of props.models) total += m.cacheSavedMicroUsd ?? 0
return total
})
function onReferenceUpdate(value: unknown): void {
if (typeof value === 'string') emit('update:refModel', value)
}
</script>
<template>
<Card>
<CardHeader class="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
<div>
<CardTitle>Cost avoided</CardTitle>
<CardDescription>What free-tier traffic in range would have cost at a real model's own rates.</CardDescription>
</div>
<div v-if="priceableOptions.length > 0" class="flex flex-col gap-1">
<label id="cost-avoided-ref-label" class="text-xs font-medium text-muted-foreground">Reference model</label>
<Select :model-value="effectiveReference ?? undefined" @update:model-value="onReferenceUpdate">
<SelectTrigger aria-labelledby="cost-avoided-ref-label" class="w-56">
<SelectValue placeholder="Pick a reference model" />
</SelectTrigger>
<SelectContent>
<SelectItem v-for="opt in priceableOptions" :key="opt.id" :value="opt.id">{{ opt.id }}</SelectItem>
</SelectContent>
</Select>
</div>
</CardHeader>
<CardContent class="grid grid-cols-2 gap-4 text-sm">
<div>
<p class="text-muted-foreground">Cost avoided (free-tier traffic)</p>
<p v-if="avoidedMicros === null" class="text-sm text-muted-foreground">
{{ priceableOptions.length === 0 ? 'No priced model served in range to compare against.' : 'Pick a reference model above.' }}
</p>
<p v-else class="text-lg font-semibold tabular-nums text-chart-requests">{{ formatCost(avoidedMicros) }}</p>
</div>
<div v-if="cacheStatsEnabled">
<p class="text-muted-foreground">Cache savings</p>
<p v-if="!cacheSavingsAvailable" class="text-lg font-semibold tabular-nums text-muted-foreground">n/a</p>
<p v-else class="text-lg font-semibold tabular-nums text-chart-requests">{{ formatCost(cacheSavedMicros) }}</p>
<p v-if="cacheSavingsNote" class="text-xs text-status-warn">{{ cacheSavingsNote }}</p>
</div>
</CardContent>
</Card>
</template>
+15 -12
View File
@@ -7,27 +7,30 @@ import { formatCost } from '@/lib/format'
import type { LimitsConfig } from '@/types/api'
/**
* CostForecastCard renders the Usage tab's fleet-wide cost projection —
* F7, placed under the Total card (UsageView.vue). All math is delegated
* to lib/forecast.ts's pure functions; this component only formats and
* lays the result out.
* CostForecastCard renders a fleet- or scope-wide cost projection — F7,
* originally the pre-redesign Usage tab's own card, now used by both
* SpendPage.vue (the global range's own scope, under the burn-down chart)
* and ConsumerDirectory.vue (fleet-wide, under the Total row). All math is
* delegated to lib/forecast.ts's pure functions; this component only
* formats and lays the result out.
*
* `now` is a REQUIRED prop, not read from `new Date()` inside this
* component (coordinator brief: "now passed as prop for determinism") —
* the caller (UsageView.vue) supplies it, keeping every `Date.now()`-
* flavored read in this feature at the boundary rather than buried inside
* a component, same discipline lib/forecast.ts's own functions already
* follow by taking `now`/`elapsedFraction` as plain arguments.
* every caller supplies it from its OWN composables/useNow.ts instance,
* keeping every `Date.now()`-flavored read in this feature at the
* boundary rather than buried inside a component, same discipline
* lib/forecast.ts's own functions already follow by taking
* `now`/`elapsedFraction` as plain arguments.
*
* P1 review fix: this used to be optional with a `withDefaults(...,
* { now: () => new Date() })` default. Vue caches a prop's default value
* ONCE per component instance (propsDefaults) — the very first time it is
* read, not on every render — so a caller that omitted `now` froze this
* card's projection at whatever instant it happened to mount, never
* advancing again for as long as the tab stayed open. `now` is now
* required: UsageView.vue passes the ONE shared reactive clock
* (composables/useNow.ts) every time-dependent read on this tab shares,
* so there is no default left to accidentally freeze.
* advancing again for as long as the page stayed open. `now` is now
* required: every caller passes the ONE shared reactive clock its own
* useNow() call returns, so there is no default left to accidentally
* freeze.
*/
const props = defineProps<{
/** usage.total.costPerMonthMicroUsd — fleet-wide month-to-date spend. */
+5 -5
View File
@@ -7,11 +7,11 @@ import { downloadText, type DownloadOutcome } from '@/lib/download'
/**
* CsvExportButton is the one "Export CSV" control every table export in
* this panel uses — F8. Owned by WP-B1 (usage-csv.ts, this component) but
* consumed unmodified by WP-B2's Models tab, which builds its own CSV text
* via lib/usage-csv.ts's modelDetailCsv and passes it through the same
* `build` prop — one export affordance, not a copy per tab (vue.md: "if
* you've written it twice, you owe an abstraction").
* this panel uses (F8) — ConsumerDirectory.vue's Users/Groups exports
* (lib/usage-csv.ts's usageCsv) and PricingHealthTable.vue's own
* pricing-health export each build their own CSV text and pass it
* through the same `build` prop — one export affordance, not a copy per
* table (vue.md: "if you've written it twice, you owe an abstraction").
*
* `build` is called fresh on every click, never memoized — an export
* always reflects whatever rows are CURRENTLY visible (filtered + sorted)
+2 -2
View File
@@ -43,8 +43,8 @@ const props = defineProps<{
* default) keeps every existing call site's original UNCONTROLLED
* behavior unchanged — this component owns `internalSorting` itself and
* a caller has no way to read the current sort. Passed, a caller (e.g.
* UsageView.vue's Users export, which needs to export rows in the
* table's own current sort order — the plan's own "exports
* ConsumerDirectory.vue's Users export, which needs to export rows in
* the table's own current sort order — the plan's own "exports
* filtered+sorted rows" requirement) instead owns the ref: every header
* click emits `update:sorting` with the new state rather than mutating
* anything locally here.
+57
View File
@@ -0,0 +1,57 @@
<script setup lang="ts">
import { faArrowUpRightFromSquare } from '@fortawesome/free-solid-svg-icons'
import { useNavStore } from '@/stores/nav'
/**
* EntityLink (redesign-plan.md section 3.5) is the redesigned shell's own
* navigation link for an entity id: a user id opens Consumers pre-filtered
* to that user (lib/usage-columns.ts's id cell, UserDetail.vue), a model
* id opens Models pre-filtered to that model (lib/model-table-columns.ts's
* id cell) — nav.goTo rather than a bare href, so it stays inside the
* SPA's own hash-routed shell. It replaced ScopeLink.vue (deleted, section
* 3.5) once every one of that component's own call sites had switched
* over.
*
* Structural note carried over from ScopeLink.vue: a real
* `<button type="button">`, always rendered as a SIBLING of whatever
* interactive element it sits beside (a table cell, an accordion header),
* never nested inside one — the HTML button content model forbids a
* focusable descendant, and nesting one breaks both keyboard operation
* and how assistive technology exposes it.
*/
const props = defineProps<{
/** The visible + accessible label, e.g. a user id or model id. */
label: string
/** Which page this id opens — 'user' -> Consumers, 'model' -> Models. */
kind: 'user' | 'model'
/** The raw entity id (never pre-formatted as "kind:id" — this component builds nav.goTo's own params itself). */
id: string
}>()
const nav = useNavStore()
const TARGET: Record<'user' | 'model', { page: 'consumers' | 'models'; paramKey: 'user' | 'model'; noun: string }> = {
user: { page: 'consumers', paramKey: 'user', noun: 'Consumers' },
model: { page: 'models', paramKey: 'model', noun: 'Models' },
}
function onClick(event: MouseEvent): void {
event.stopPropagation()
const target = TARGET[props.kind]
nav.goTo(target.page, { [target.paramKey]: props.id })
}
</script>
<template>
<button
type="button"
:aria-label="`View ${label} in ${TARGET[kind].noun}`"
:title="`View ${label} in ${TARGET[kind].noun}`"
class="inline-flex cursor-pointer items-center gap-1 rounded-sm text-inherit hover:text-primary hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/50"
@click="onClick"
>
<span>{{ label }}</span>
<FontAwesomeIcon :icon="faArrowUpRightFromSquare" class="size-3" aria-hidden="true" />
</button>
</template>
+6 -5
View File
@@ -25,8 +25,9 @@ const ALL_KINDS = 'all'
// kindFilter/userFilter live on the store, not as local refs (F9,
// hash-state): composables/useHashState.ts reads/writes them directly so
// this filter round-trips through the URL hash. userFilter binds through
// useSearchQuery's own external-ref option, the same way UsageView.vue
// binds nav.usageQuery — see useSearchQuery.ts's own doc comment.
// useSearchQuery's own external-ref option — see useSearchQuery.ts's own
// doc comment for the other current external-ref caller
// (ConsumerDirectory.vue).
const { kindFilter, userFilter } = storeToRefs(events)
const { normalized: normalizedUserQuery } = useSearchQuery(userFilter)
@@ -48,9 +49,9 @@ function onKindChange(value: unknown): void {
const columns = eventsColumns()
/**
* emptyMessage mirrors TargetsView.vue/UsageView.vue's own three-way
* pattern: distinguishes "nothing recorded at all" from "nothing matches
* the active filter".
* emptyMessage mirrors TargetsView.vue/ConsumerDirectory.vue's own
* three-way pattern: distinguishes "nothing recorded at all" from
* "nothing matches the active filter".
*
* P9 review fix: gated on events.lastUpdated (null until the first fetch
* settles, stores/events.ts) — this used to read events.events.length
+134
View File
@@ -0,0 +1,134 @@
<script setup lang="ts">
import { faArrowRightArrowLeft } from '@fortawesome/free-solid-svg-icons'
import { computed, ref, watch } from 'vue'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { SCOPE_PATTERN } from '@/lib/hash-state'
import { RANGE_KEYS } from '@/lib/range'
import type { RangeKey } from '@/lib/range'
import { DEFAULT_SCOPE, useFiltersStore } from '@/stores/filters'
/** RANGE_LABEL is the range Select's display text per lib/range.ts's RangeKey — kept here (not lib/range.ts) since it is pure presentation, not logic a store or another page's fetch needs. */
const RANGE_LABEL: Record<RangeKey, string> = {
'24h': 'Last 24 hours',
'48h': 'Last 48 hours',
'7d': 'Last 7 days',
'14d': 'Last 14 days',
'30d': 'Last 30 days',
'3mo': 'Last 3 months',
'6mo': 'Last 6 months',
'12mo': 'Last 12 months',
}
/**
* GlobalFilterBar (redesign-plan.md section 3.1) is the shell's one
* control surface for the three global filters (stores/filters.ts):
* time range, "vs previous period" comparison, and an optional
* group/user/provider scope narrowing. Every page reads the SAME filters
* store; this bar is mounted once, in App.vue's header, not per-page.
*/
const filters = useFiltersStore()
function onRangeUpdate(value: unknown): void {
if (typeof value !== 'string') return
filters.setFilters({ range: value as RangeKey })
}
/**
* toggleCmp (P3 item 19): turning the comparison OFF is always allowed,
* even when the CURRENT range does not support one — a `cmp=prev` value
* restored from the hash (e.g. loaded directly onto a 48h/30d/12mo range,
* where 2*span exceeds the retention ceiling) used to leave the button
* permanently pressed-and-disabled, with no way to turn it off short of
* changing the range first. filters.cmpSpec.requested already gates the
* actual fetch on availability (stores/filters.ts's own doc comment), so
* this was purely a stuck AFFORDANCE, never a bad request. Turning it ON
* still requires availability — there is nothing to compare against
* otherwise.
*/
function toggleCmp(): void {
if (filters.cmp === 'prev') {
filters.setFilters({ cmp: 'none' })
return
}
if (!filters.cmpSpec.available) return
filters.setFilters({ cmp: 'prev' })
}
/** cmpDisabled only blocks turning the toggle ON when unavailable — turning an already-on comparison OFF is never blocked (toggleCmp's own doc comment). */
const cmpDisabled = computed<boolean>(() => filters.cmp !== 'prev' && !filters.cmpSpec.available)
const cmpTitle = computed<string>(() => {
if (filters.cmp === 'prev' && !filters.cmpSpec.available) {
return `Comparison unavailable for this range (${filters.cmpSpec.reason}) — click to turn off`
}
if (filters.cmpSpec.available) return 'Compare against the previous equal-length period'
return `Comparison unavailable for this range: ${filters.cmpSpec.reason}`
})
// scopeDraft is a local editing buffer, not bound straight to
// filters.scope: a partially-typed value ("group:") must not spam
// setFilters/refetch every store on every keystroke, and an invalid
// commit (onBlur/Enter, below) must revert the FIELD, not the store —
// the store keeps whatever scope was last valid.
const scopeDraft = ref(filters.scope)
watch(
() => filters.scope,
(scope) => {
scopeDraft.value = scope
},
)
function commitScope(): void {
const value = scopeDraft.value.trim() || DEFAULT_SCOPE
if (SCOPE_PATTERN.test(value)) {
filters.setFilters({ scope: value })
} else {
// Invalid — revert the field to the store's own current value rather than keeping the bad text on screen.
scopeDraft.value = filters.scope
}
}
</script>
<template>
<div class="flex flex-wrap items-end gap-3">
<div class="flex flex-col gap-1">
<label id="global-filter-range-label" class="text-xs font-medium text-muted-foreground">Range</label>
<Select :model-value="filters.range" @update:model-value="onRangeUpdate">
<SelectTrigger aria-labelledby="global-filter-range-label" class="w-36">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem v-for="key in RANGE_KEYS" :key="key" :value="key">{{ RANGE_LABEL[key] }}</SelectItem>
</SelectContent>
</Select>
</div>
<Button
type="button"
variant="outline"
:aria-pressed="filters.cmp === 'prev'"
:disabled="cmpDisabled"
:title="cmpTitle"
@click="toggleCmp"
>
<FontAwesomeIcon :icon="faArrowRightArrowLeft" class="size-3.5" aria-hidden="true" />
vs previous period
</Button>
<div class="flex flex-col gap-1">
<label for="global-filter-scope" class="text-xs font-medium text-muted-foreground">Scope</label>
<Input
id="global-filter-scope"
v-model="scopeDraft"
class="w-48"
placeholder="all"
title="all, group:<name>, user:<name>, or provider:<name>"
@keyup.enter="commitScope"
@blur="commitScope"
/>
</div>
</div>
</template>
+70
View File
@@ -0,0 +1,70 @@
<script setup lang="ts">
import { computed } from 'vue'
import { Card, CardContent } from '@/components/ui/card'
import type { KpiTier } from '@/lib/kpi'
import type { PageId } from '@/lib/pages'
import { useNavStore } from '@/stores/nav'
/**
* KpiTile (redesign-plan.md section 3.4) is the Home page's one KPI-tile
* shape: a label, a headline value, an optional delta/detail line, and a
* tier (lib/kpi.ts) that colors the value text — 'ok' (default
* foreground), 'warn' (--status-warn), 'critical' (--destructive), the
* same two semantic tokens the rest of this panel's badges/bars already
* use, never a fourth ad-hoc color.
*
* Optionally a link: when `to` is set, the whole tile becomes a real
* `<button>` that calls nav.goTo(to.page, to.params) — a budget tile
* jumps to Spend, an open-breakers tile jumps to Reliability, and so on.
* Without `to`, it renders as a plain, non-interactive Card (a tile with
* nowhere useful to send the reader, e.g. "requests/min", stays inert
* rather than a button that does nothing).
*/
const props = withDefaults(
defineProps<{
label: string
value: string
delta?: string
tier?: KpiTier
to?: { page: PageId; params?: Record<string, string> }
}>(),
{ tier: 'ok' },
)
const nav = useNavStore()
const valueClass = computed<string>(() => {
switch (props.tier) {
case 'warn':
return 'text-status-warn'
case 'critical':
return 'text-destructive'
default:
return 'text-foreground'
}
})
function onClick(): void {
if (!props.to) return
nav.goTo(props.to.page, props.to.params ?? {})
}
</script>
<template>
<component
:is="to ? 'button' : 'div'"
:type="to ? 'button' : undefined"
class="block w-full rounded-xl text-left"
:class="to ? 'cursor-pointer transition-colors hover:opacity-90 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/50' : undefined"
@click="onClick"
>
<Card>
<CardContent class="flex flex-col gap-1 px-4 py-3">
<span class="text-xs font-medium text-muted-foreground">{{ label }}</span>
<span class="text-2xl font-semibold tracking-tight tabular-nums" :class="valueClass">{{ value }}</span>
<span v-if="delta" class="text-xs text-muted-foreground">{{ delta }}</span>
</CardContent>
</Card>
</component>
</template>
@@ -0,0 +1,69 @@
<script setup lang="ts">
import { computed } from 'vue'
import DataTable from '@/components/DataTable.vue'
import SearchInput from '@/components/SearchInput.vue'
import { Alert, AlertDescription } from '@/components/ui/alert'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { filterModelRows } from '@/lib/model-filter'
import { modelCatalogColumns } from '@/lib/model-table-columns'
import type { ModelCatalogRow } from '@/lib/model-table-columns'
/**
* ModelCatalogTable (redesign-plan.md section 3.4, Models & providers
* page) is the catalog-driven table: every configured model (not just
* ones with observed traffic), joined with the current range's ranking
* and fleet performance — ModelCatalogRow's own doc comment
* (lib/model-table-columns.ts) has the full join contract.
*
* `query` is a controlled v-model, not owned locally: ModelsPage.vue keeps
* it so the Models page's own `q` hash param (redesign-plan.md section
* 3.1) round-trips through the URL, the same controlled-component
* convention SearchInput.vue itself already documents.
*/
const props = defineProps<{
rows: ModelCatalogRow[]
query: string
/** GET /admin/api/performance's own latencyEnabled (AdminPerfResponse) — false means admin.stats.latency is off, so p50/p95 read "—" for every row, not "no traffic yet". */
latencyEnabled: boolean
loading: boolean
}>()
const emit = defineEmits<{ 'update:query': [value: string] }>()
const filtered = computed<ModelCatalogRow[]>(() => filterModelRows(props.rows, props.query))
const columns = modelCatalogColumns()
const emptyMessage = computed<string>(() => {
if (props.loading) return 'loading…'
if (props.rows.length === 0) return 'no models configured'
if (props.query.trim()) return `no models match "${props.query}"`
return 'none'
})
</script>
<template>
<Card>
<CardHeader>
<CardTitle>Model catalog</CardTitle>
<CardDescription>
Every configured model: price source, per-model usage for the current range, fleet p50/p95, and error rate.
</CardDescription>
<SearchInput
:model-value="query"
placeholder="Search models or aliases..."
class="mt-2 max-w-sm"
@update:model-value="emit('update:query', $event)"
/>
</CardHeader>
<CardContent class="flex flex-col gap-3">
<Alert v-if="!latencyEnabled" variant="warn">
<AlertDescription>
Latency percentiles (p50/p95) are off for this deployment. Enable <code>admin.stats.latency</code> to see them.
</AlertDescription>
</Alert>
<DataTable :columns="columns" :data="filtered" :empty-message="emptyMessage" />
</CardContent>
</Card>
</template>
+9 -7
View File
@@ -9,13 +9,15 @@ import { formatContextWindow, formatModelCostHover } from '@/lib/format'
/**
* ModelChip is one clickable, copyable model-id chip, used anywhere a
* resolvable model or alias id renders: a provider's expanded model list
* (ProvidersView.vue's accordion), the model-alias table, and a group's
* usage-detail model list (UsageView.vue — passed with no metadata props
* there, since a raw group-config glob entry has no resolvable provider/
* model pair to look metadata up against). id is the full routable id
* (lib/format.ts's routableModelId) or an alias name — what actually
* gets copied, so a pasted value works straight into a `model: "..."`
* request field.
* and the model-alias table (both ProviderHealthPanel.vue's accordion),
* the Models page's own catalog table (lib/model-table-columns.ts, WITH
* metadata props — see contextTokens/inputPerMTokUsd/outputPerMTokUsd
* below), and a group's own allowed-models list (ConsumerDirectory.vue —
* passed with no metadata props there for a raw glob entry, since it has
* no resolvable provider/model pair to look metadata up against). id is
* the full routable id (lib/format.ts's routableModelId) or an alias
* name — what actually gets copied, so a pasted value works straight
* into a `model: "..."` request field.
*
* contextTokens/inputPerMTokUsd/outputPerMTokUsd are this model's
* resolved metadata (feature v0.23, hover-detail refinement:
-119
View File
@@ -1,119 +0,0 @@
<script setup lang="ts">
import type { ChartData, ChartOptions } from 'chart.js'
import { computed } from 'vue'
import { Bar } from 'vue-chartjs'
import '@/lib/chart-setup'
import { useThemeColors } from '@/composables/useThemeColors'
import { formatCompactCount, formatCost, formatExactInt } from '@/lib/format'
import { MODEL_METRIC_LABEL, type ModelMetric } from '@/stores/history'
import type { AdminUsageModelEntry } from '@/types/api'
const props = defineProps<{
metric: ModelMetric
models: AdminUsageModelEntry[]
}>()
const { foreground, mutedForeground, border } = useThemeColors()
/**
* One token per rankable metric, reusing the SAME --chart-* custom
* properties the time-series chart uses (UsageChart.vue's DATASET_SPECS),
* so a model's bar is the colour that metric already has elsewhere in the
* view rather than a second, conflicting palette.
*/
const METRIC_COLOR: Record<ModelMetric, string> = {
req: '--chart-requests',
tokin: '--chart-tokens-in',
tokout: '--chart-tokens-out',
cost: '--chart-cost',
}
/** Cost is stored as micro-USD (limits.go's account/history convention); every other metric is already the display unit. */
function toDisplayValue(metric: ModelMetric, raw: number): number {
return metric === 'cost' ? raw / 1_000_000 : raw
}
/** resolveToken reads a --chart-* custom property from :root, live — same approach (and same reason) as UsageChart.vue. */
function resolveToken(cssVar: string): string {
return getComputedStyle(document.documentElement).getPropertyValue(cssVar).trim()
}
/**
* The server already returns the ranking sorted, descending, and capped —
* this component neither re-sorts nor re-filters it. Chart.js draws the
* first category at the TOP of a horizontal bar chart, so the busiest
* model lands where a reader looks first, with no reversal needed.
*/
const chartData = computed<ChartData<'bar'>>(() => {
void foreground.value // re-run on a prefers-color-scheme flip (see UsageChart.vue)
return {
labels: props.models.map((m) => m.id),
datasets: [
{
label: MODEL_METRIC_LABEL[props.metric],
backgroundColor: resolveToken(METRIC_COLOR[props.metric]),
data: props.models.map((m) => toDisplayValue(props.metric, m.value)),
},
],
}
})
/**
* Height grows with the row count so bars keep a readable thickness
* instead of compressing into hairlines at 20 models, and so a two-model
* ranking does not stretch each bar across a third of the screen.
*/
const chartHeight = computed<string>(() => `${Math.max(180, props.models.length * 28 + 60)}px`)
const chartOptions = computed<ChartOptions<'bar'>>(() => ({
indexAxis: 'y',
responsive: true,
maintainAspectRatio: false,
animation: false,
scales: {
x: {
// Zero-based, honest axis: bar length is the whole comparison here.
beginAtZero: true,
grid: { color: border.value },
ticks: {
color: mutedForeground.value,
// Reuses formatCost, same rationale as UsageChart.vue's identical
// fix: a raw tick float interpolated directly into "$${value}" can
// print unrounded binary-float noise; formatCost's .toFixed(4)
// resolves it, and is the same formatter every other cost figure
// in this app already renders through.
callback: (value) => (props.metric === 'cost' ? formatCost(Number(value) * 1_000_000) : formatCompactCount(Number(value))),
},
},
y: {
grid: { display: false },
ticks: { color: mutedForeground.value, autoSkip: false },
},
},
plugins: {
legend: { display: false },
tooltip: {
callbacks: {
// Compact figure plus the exact one, matching UsageChart.vue's
// tooltip contract — a tooltip has room for both, and precision
// must never be lost to the axis's compact form.
label: (ctx) => {
const value = ctx.parsed.x ?? 0
if (props.metric === 'cost') return `${MODEL_METRIC_LABEL[props.metric]}: $${value.toFixed(4)}`
return `${MODEL_METRIC_LABEL[props.metric]}: ${formatCompactCount(value)} (${formatExactInt(value)})`
},
},
},
},
}))
</script>
<template>
<p v-if="models.length === 0" class="py-8 text-center text-sm text-muted-foreground">
No model recorded any usage in this window.
</p>
<div v-else class="w-full" :style="{ height: chartHeight }">
<Bar :data="chartData" :options="chartOptions" />
</div>
</template>
+152
View File
@@ -0,0 +1,152 @@
<script setup lang="ts">
import type { ColumnDef } from '@tanstack/vue-table'
import { computed, h } from 'vue'
import CompactNumber from '@/components/CompactNumber.vue'
import CsvExportButton from '@/components/CsvExportButton.vue'
import DataTable from '@/components/DataTable.vue'
import EntityLink from '@/components/EntityLink.vue'
import { Badge } from '@/components/ui/badge'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { toCsv } from '@/lib/csv'
import { formatContextWindow, formatCost, formatModelCostHover } from '@/lib/format'
import { joinPricingHealth, pricingHealthOrder } from '@/lib/pricing-health'
import type { PricingHealthRow } from '@/lib/pricing-health'
import type { AdminCatalogModel, AdminUsageModelEntry, PriceSource } from '@/types/api'
/**
* PricingHealthTable (redesign-plan.md section 3.4) lists every model
* actually served in the current range, joined with its catalog entry
* (lib/pricing-health.ts's joinPricingHealth), unpriced first with a
* destructive badge (pricingHealthOrder) — the row an operator most needs
* to act on (a served model billing at $0 because no pricing rule
* matched) is always the first thing they see, before any manual sort.
*/
const props = defineProps<{
models: AdminUsageModelEntry[]
catalog: Map<string, AdminCatalogModel>
}>()
const rows = computed<PricingHealthRow[]>(() => pricingHealthOrder(joinPricingHealth(props.models, props.catalog)))
const unpricedCount = computed<number>(() => rows.value.filter((r) => r.priceSource === 'unpriced').length)
/** SOURCE_BADGE_VARIANT mirrors lib/model-table-columns.ts's own priceCell badge treatment (WP-F, ModelCatalogTable.vue) so the two tables read consistently: destructive for unpriced (needs attention), secondary for a deliberate free model, outline for an explicit rule. */
const SOURCE_BADGE_VARIANT: Record<PriceSource, 'destructive' | 'secondary' | 'outline'> = {
unpriced: 'destructive',
free: 'secondary',
override: 'outline',
builtin: 'outline',
litellm: 'outline',
}
function sourceCell(row: PricingHealthRow) {
return h(Badge, { as: 'span', variant: SOURCE_BADGE_VARIANT[row.priceSource], class: 'font-normal' }, () => row.priceSource)
}
function priceCell(row: PricingHealthRow) {
if (row.inputPerMTokUsd === undefined || row.outputPerMTokUsd === undefined) return h('span', { class: 'text-muted-foreground' }, '—')
return h('span', {}, formatModelCostHover(row.inputPerMTokUsd, row.outputPerMTokUsd))
}
const columns: ColumnDef<PricingHealthRow, unknown>[] = [
{
id: 'id',
header: 'Model',
accessorFn: (r) => r.id,
cell: ({ row }) => h(EntityLink, { label: row.original.id, kind: 'model', id: row.original.id }),
},
{
id: 'source',
header: 'Source',
accessorFn: (r) => r.priceSource,
cell: ({ row }) => sourceCell(row.original),
},
{
id: 'price',
header: 'Price',
enableSorting: false,
cell: ({ row }) => priceCell(row.original),
},
{
id: 'context',
header: 'Context',
meta: { align: 'right' },
accessorFn: (r) => r.contextTokens ?? 0,
cell: ({ row }) => h('span', { class: 'tabular-nums' }, row.original.contextTokens !== undefined ? formatContextWindow(row.original.contextTokens) : '—'),
},
{
id: 'requests',
header: 'Requests',
meta: { align: 'right' },
accessorFn: (r) => r.requests,
cell: ({ row }) => h(CompactNumber, { value: row.original.requests }),
},
{
id: 'tokensIn',
header: 'Tokens in',
meta: { align: 'right' },
accessorFn: (r) => r.tokensIn,
cell: ({ row }) => h(CompactNumber, { value: row.original.tokensIn }),
},
{
id: 'tokensOut',
header: 'Tokens out',
meta: { align: 'right' },
accessorFn: (r) => r.tokensOut,
cell: ({ row }) => h(CompactNumber, { value: row.original.tokensOut }),
},
{
id: 'cost',
header: 'Cost',
meta: { align: 'right' },
accessorFn: (r) => r.costMicroUsd,
cell: ({ row }) => formatCost(row.original.costMicroUsd),
},
]
/**
* pricingHealthCsv exports exactly the rows on screen (unpriced-first
* order, or whatever the reader has since re-sorted to — DataTable's own
* `data` prop is passed through unmodified) as raw, unformatted values
* (CsvExportButton.vue's own doc comment: "always reflects whatever rows
* are CURRENTLY visible... never a stale snapshot") — costMicroUsd stays
* micro-USD, not a pre-formatted "$X.XXXX" string, so the export re-opens
* cleanly as a spreadsheet numeric column.
*/
function pricingHealthCsv(): string {
return toCsv(
['id', 'priceSource', 'displayFree', 'requests', 'tokensIn', 'tokensOut', 'costMicroUsd', 'contextTokens', 'inputPerMTokUsd', 'outputPerMTokUsd'],
rows.value.map((r) => [
r.id,
r.priceSource,
r.displayFree,
r.requests,
r.tokensIn,
r.tokensOut,
r.costMicroUsd,
r.contextTokens ?? '',
r.inputPerMTokUsd ?? '',
r.outputPerMTokUsd ?? '',
]),
)
}
</script>
<template>
<Card>
<CardHeader>
<CardTitle class="flex items-center gap-2">
Pricing health
<Badge v-if="unpricedCount > 0" variant="destructive" class="font-normal">{{ unpricedCount }} unpriced</Badge>
</CardTitle>
<CardDescription>Every model served in range, joined with its billing price source.</CardDescription>
</CardHeader>
<CardContent class="flex flex-col gap-3">
<DataTable :columns="columns" :data="rows" empty-message="No model served any traffic in this range." />
<div class="flex justify-end">
<CsvExportButton filename="pricing-health.csv" :build="pricingHealthCsv" />
</div>
</CardContent>
</Card>
</template>
@@ -8,11 +8,12 @@ import {
import { computed, h, reactive, watch } from 'vue'
import DataTable from '@/components/DataTable.vue'
import EntityLink from '@/components/EntityLink.vue'
import ModelChip from '@/components/ModelChip.vue'
import ProviderRateBadge from '@/components/ProviderRateBadge.vue'
import ScopeLink from '@/components/ScopeLink.vue'
import SearchInput from '@/components/SearchInput.vue'
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from '@/components/ui/accordion'
import { Alert, AlertDescription } from '@/components/ui/alert'
import { Badge } from '@/components/ui/badge'
import {
Card,
@@ -26,79 +27,55 @@ import { formatAgo, formatContextWindow, formatLatencyMs, formatUntil, refreshDe
import { isModelDegraded } from '@/lib/provider-rate'
import { type ExpandState, clearExpandOverrides, computeExpandedItems, toggleItemExpand } from '@/lib/search-expand'
import { useDashboardStore } from '@/stores/dashboard'
import { useModelsStore } from '@/stores/models'
import { useNavStore } from '@/stores/nav'
import type { AdminAliasView, AdminLatencyView, AdminModelMetaView, AdminModelRateView, AdminProvenanceView, AdminProviderView } from '@/types/api'
import type {
AdminAliasView,
AdminLatencyView,
AdminModelMetaView,
AdminModelRateView,
AdminPerfRow,
AdminProvenanceView,
AdminProviderView,
} from '@/types/api'
// This component backs the "Providers" tab (App.vue) — a UI-label rename
// only. The data it renders still comes from GET /admin/api/overview
// (admin.go's adminOverviewResponse) and the store field below keeps that
// same "overview" name, since the backend route/type names are unchanged
// API surface.
/**
* ProviderHealthPanel (redesign-plan.md section 3.4, Models & providers
* page) is ProvidersView.vue's own Providers accordion + aliases table,
* moved here verbatim, PLUS fleet-wide performance (p50/p95, timeouts,
* failovers — GET /admin/api/performance?kind=provider, read from
* useModelsStore, which the Models page's own onMounted fetches once for
* the whole page, shared with ModelCatalogTable.vue). The per-replica
* discovery/latency/provenance badges below are UNCHANGED from
* ProvidersView.vue — still sourced from the SAME 5s dashboard poll
* (GET /admin/api/overview), still per-replica/in-process (see each
* badge's own doc comment for why that never averages across replicas).
*/
const dashboard = useDashboardStore()
const models = useModelsStore()
const overview = computed(() => dashboard.overview)
const nav = useNavStore()
/**
* goToProviderModels navigates to the Charts Models ranking, pre-filtered
* to this provider's own models (F6, dashboard-plan.md: `nav.goToModels`).
*
* P2 review fix: this used to back a `<span role="link" tabindex="0">`
* nested INSIDE AccordionTrigger's own real `<button>` — invalid per the
* HTML button content model (no descendant may carry a `tabindex`
* attribute, not just no nested button/anchor) and effectively invisible
* to assistive technology (ARIA's `button` role declares "children
* presentational: true", so the nested span was never announced as its
* own link — see ScopeLink.vue's own doc comment for the identical
* finding there). The provider-name label is now a real `<button>`,
* rendered as a SIBLING of AccordionTrigger rather than nested inside it
* (this component's template) — a plain click handler is enough: a click
* on a sibling element never bubbles INTO the trigger's own listener
* (event bubbling only travels through actual ancestors of the click
* target), so the stopPropagation/Enter-only-keydown machinery this used
* to need is gone along with the span it protected — native `<button>`
* keyboard activation (Enter AND Space) is correct here for free.
* goToProviderModels navigates to the Models catalog table, pre-filtered
* (via its own `q` search) to this provider's own models — the redesign's
* replacement for the pre-redesign nav.goToModels (stores/nav.ts no
* longer has a per-page goTo* method at all, see that store's own doc
* comment; every navigation goes through the single goTo(page, params)).
*/
function goToProviderModels(providerName: string): void {
nav.goToModels(`${providerName}/`)
nav.goTo('models', { q: `${providerName}/` })
}
// --- provider expand/collapse (shadcn-vue Accordion) ---
//
// Providers render as a real shadcn-vue Accordion (operator directive:
// use the library component, restructure the layout to fit it — the
// earlier hand-rolled table-row-toggle accordion is retired). The
// trigger is one provider's summary line; the content holds its detail
// (base URL, last refresh, last error) plus its model-id chips.
//
// Expand STATE itself is unchanged: lib/search-expand.ts's plain,
// Vue-free two-set module (manuallyExpanded/manuallyCollapsed, generalized
// from provider-expand.ts so UsageView.vue's Groups accordion shares the
// same tested logic) — the exact search-interaction bug fix a prior review
// flagged, covered by a real vitest spec (lib/search-expand.spec.ts) that
// exercises the module directly, without mounting Vue. Only the
// template-facing adapter changed: expandedProviderValues below is a
// writable computed translating that Set-based state into the string[]
// shape Accordion's `type="multiple"` v-model expects, and back —
// clicking a trigger fires the setter with the new array; diffing it
// against the previous effective set finds the one name that changed
// and replays it through the SAME tested toggleItemExpand used before, so
// Accordion never owns this state itself, only displays it.
// --- provider expand/collapse (shadcn-vue Accordion) — unchanged from ProvidersView.vue ---
const expandState: ExpandState = reactive({
manuallyExpanded: new Set<string>(),
manuallyCollapsed: new Set<string>(),
})
// --- model/alias search filter (operator feature) ---
// --- model/alias search filter (operator feature) — unchanged from ProvidersView.vue ---
const { query: modelQuery, normalized: normalizedQuery, hasQuery } = useSearchQuery()
/**
* Matching is against each model's full ROUTABLE id
* (routableModelId(p.name, m) — the same string ModelChip both displays
* and copies), not the bare model id: copying a chip's text and pasting
* it back into search must find it, and the routable form is strictly
* more permissive (it contains the bare id as a substring), so this
* never hides a match the bare-id form would have found.
*/
function modelMatches(providerName: string, modelId: string): boolean {
return routableModelId(providerName, modelId).toLowerCase().includes(normalizedQuery.value)
}
@@ -118,7 +95,6 @@ const filteredAliases = computed<AdminAliasView[]>(() => {
return hasQuery.value ? all.filter(aliasMatches) : all
})
/** expandedProviders is the EFFECTIVE (possibly auto-expanded-by-search) set — see lib/search-expand.ts's own doc comments for the manual/auto-expand/override semantics. */
const expandedProviders = computed<Set<string>>(() =>
computeExpandedItems(
expandState,
@@ -127,18 +103,10 @@ const expandedProviders = computed<Set<string>>(() =>
),
)
/** toggleProvider reads the CURRENT effective (visible) state for name before flipping it — see toggleItemExpand's own doc comment for exactly which bug this avoids. */
function toggleProvider(name: string): void {
toggleItemExpand(expandState, name, expandedProviders.value.has(name))
}
/**
* expandedProviderValues adapts expandedProviders (a Set) to Accordion's
* `type="multiple"` v-model contract (a string[]): reads out as
* Array.from(expandedProviders.value); writes replay each name whose
* membership changed through toggleProvider — Accordion never mutates
* expandState directly, it only tells this setter what changed.
*/
const expandedProviderValues = computed<string[]>({
get: () => Array.from(expandedProviders.value),
set: (newValues) => {
@@ -152,58 +120,33 @@ const expandedProviderValues = computed<string[]>({
},
})
// A stale manuallyCollapsed suppression from one search must never
// silently carry into a later, unrelated one — see ExpandState's own doc
// comment. Watching modelQuery (not just the explicit clear button)
// covers backspacing to empty too.
watch(modelQuery, (value) => {
if (value.trim() === '') clearExpandOverrides(expandState)
})
/** visibleModels is p's own model list, filtered to matches while a query is active — p only appears in filteredProviders at all because it has one, so this is never empty in that case; it exists so the accordion shows WHICH models matched instead of re-showing all 70+ with no distinction. */
function visibleModels(p: AdminProviderView): string[] {
return hasQuery.value ? p.models.filter((m) => modelMatches(p.name, m)) : p.models
}
/** ZERO_MODEL_RATE is the fallback ProviderRateBadge reads for a model p.modelRates has no entry for — should not happen (admin.go's buildAdminOverview populates one entry per Models id, unconditionally), but a defensive fallback keeps a stale/mismatched client build from throwing rather than just under-reporting. */
const ZERO_MODEL_RATE: AdminModelRateView = { attemptsDay: 0, failuresDay: 0 }
/**
* modelRateFor looks up one model's counters within p.modelRates (Feature
* A, v0.22), falling back to ZERO_MODEL_RATE. The optional-chain on
* modelRates itself (folded review minor, v0.22 review round), not just
* on the lookup, matches ZERO_MODEL_RATE's own doc comment: a stale
* client talking to an older server build (before this field existed at
* all) would otherwise throw on `.modelRates[model]` rather than fall
* back — the type says modelRates is always present, but a real response
* across a version skew is the one case that type cannot guarantee.
*/
function modelRateFor(p: AdminProviderView, model: string): AdminModelRateView {
return p.modelRates?.[model] ?? ZERO_MODEL_RATE
}
/** modelIsDegraded gates the per-model rate badge (spec: "ONLY when that model is degraded") so a healthy or no-traffic model's ModelChip renders with no badge beside it at all. */
function modelIsDegraded(p: AdminProviderView, model: string): boolean {
const r = modelRateFor(p, model)
return isModelDegraded(r.attemptsDay, r.failuresDay)
}
/** ZERO_MODEL_META is modelMetaFor's fallback, mirroring ZERO_MODEL_RATE's own doc comment above — every sub-field undefined reads as "nothing known", not a thrown error, across a version-skewed client/server pair. */
const ZERO_MODEL_META: AdminModelMetaView = {}
/** modelMetaFor looks up one model's resolved metadata within p.modelMeta (feature v0.23), falling back to ZERO_MODEL_META. */
function modelMetaFor(p: AdminProviderView, model: string): AdminModelMetaView {
return p.modelMeta?.[model] ?? ZERO_MODEL_META
}
// --- discovery circuit breaker (feat/provider-health) ---
//
// Badge shown ONLY for 'open'/'half-open' — mirroring modelIsDegraded's
// own "no badge when healthy" convention above: a 'closed' provider (the
// common case) renders no breaker badge at all, exactly like a
// non-degraded model gets no ProviderRateBadge.
// --- discovery circuit breaker (feat/provider-health) — unchanged from ProvidersView.vue ---
/** healthBadgeVariant maps healthState to the shadcn-vue Badge variant: destructive (backing off) for 'open', secondary (in progress) for 'half-open', undefined (no badge) for 'closed'. */
function healthBadgeVariant(state: AdminProviderView['healthState']): 'destructive' | 'secondary' | undefined {
switch (state) {
case 'open':
@@ -215,36 +158,17 @@ function healthBadgeVariant(state: AdminProviderView['healthState']): 'destructi
}
}
/** healthBadgeLabel renders the breaker badge's text: "open (in Ns)" while backing off (formatUntil omits the parenthetical once openUntil has passed or is unset), or "half-open" while probing. */
function healthBadgeLabel(p: AdminProviderView): string {
const until = formatUntil(p.openUntil)
return until ? `${p.healthState} (${until})` : p.healthState
}
/** healthDetailClass matches the accordion-content detail row's text color to healthBadgeVariant's own trigger-row Badge color, rather than hardcoding text-destructive for every non-closed state: destructive (red) for 'open', muted (grey, the same neutral tone secondary conveys on the Badge) for 'half-open'. */
function healthDetailClass(state: AdminProviderView['healthState']): string {
return state === 'open' ? 'text-destructive' : 'text-muted-foreground'
}
// --- upstream latency (feat: instrument upstream latency) ---
//
// Beside the discovery-health badge above: no badge at all for a stream
// state with no observations yet (p.latency undefined, or missing that
// key) — mirrors healthBadgeVariant/modelIsDegraded's own "nothing to
// show reads as no badge" convention, so absent data reads as absent,
// never as a fabricated zero.
//
// The two stream states are deliberately labeled and worded differently,
// never merged into one figure (task brief's own correctness rule):
// streaming shows avgTtfbMs, the load-sensitive reading, labeled "ttfb";
// non-streaming shows avgDurationMs labeled "avg" — NEVER "latency" or
// "ttfb" — because a non-streaming provider buffers its whole completion
// before sending anything, so its own avgTtfbMs is approximately equal
// to avgDurationMs and carries the identical output-length contamination
// a raw total-duration figure always has (a 4000-token answer legitimately
// takes longer than a 50-token one on an equally healthy provider).
// --- per-replica upstream latency (feat: instrument upstream latency) — unchanged from ProvidersView.vue ---
/** streamingLatency/nonStreamingLatency pull p.latency's two known stream-state keys (admin.go's buildAdminLatencyViews emits exactly these two strings) — undefined when that stream state has no observations yet. */
function streamingLatency(p: AdminProviderView): AdminLatencyView | undefined {
return p.latency?.streaming
}
@@ -252,57 +176,27 @@ function nonStreamingLatency(p: AdminProviderView): AdminLatencyView | undefined
return p.latency?.['non-streaming']
}
/** latencyObservationCount renders v.count (omitted, per admin.go, when 0) as a singular/plural detail phrase for a badge title, falling back to "no observations counted" for the defensive case of a present view with an absent count (version skew). */
function latencyObservationCount(v: AdminLatencyView): string {
if (v.count === undefined) return 'no observations counted'
return `${v.count} ${v.count === 1 ? 'observation' : 'observations'} on this replica`
}
/**
* perReplicaCaveat is appended to every latency/provenance badge's title
* (task brief's correctness rule 1): this figure comes from the same
* in-process accumulator the rate-limit rejection counter reads, never
* Redis, so it reflects only whichever of this deployment's several
* Traefik replicas answered the current poll — never a fleet-wide
* average, and a freshly restarted pod shows a short window. F4
* (dashboard-plan.md) names the CONCRETE replica (admin.go's g.replica,
* echoed as overview.replica) rather than the abstract warning alone — a
* reader can now tell not just THAT this figure is replica-scoped, but
* WHICH replica it came from. Falls back to the generic "this replica"
* phrasing for a version-skewed server build that has not shipped the
* field yet (undefined, never a fabricated id).
*/
function perReplicaCaveat(): string {
const replica = overview.value?.replica
const who = replica ? `replica ${replica}` : 'this replica'
return `Per-replica (${who}), in-process only — not a fleet-wide average across this deployment’s replicas.`
}
/** streamingLatencyTitle is the streaming ttfb badge's hover/title detail. */
function streamingLatencyTitle(v: AdminLatencyView): string {
return `Average time to first byte, the load-sensitive reading for streaming traffic. ${latencyObservationCount(v)}. ${perReplicaCaveat()}`
}
/** nonStreamingLatencyTitle is the non-streaming avg-duration badge's hover/title detail — explicit that this is NOT a latency/ttfb reading (correctness rule 2). */
function nonStreamingLatencyTitle(v: AdminLatencyView): string {
return `Average total response time, including generation — not a load-sensitive signal like streaming ttfb, since this provider buffers the whole completion before sending anything. ${latencyObservationCount(v)}. ${perReplicaCaveat()}`
}
// --- usage provenance (feat: expose token-accounting provenance) ---
//
// Token accounting has three provenances (see AdminProvenanceView's own
// doc comment): reported (the healthy default — no badge, mirroring the
// health/latency badges' own "nothing to show reads as no badge"
// convention), estimated (a non-streaming response carried no usage, so
// prompt was substituted from request body size), and unbilled (a
// streaming response carried no usage, so the request was counted but
// zero tokens were billed — a provider potentially serving completions
// entirely free against every budget). estimated/unbilled are two
// DISTINCT badges, never merged into one "not reported" indicator —
// collapsing them would hide exactly the fact this feature exists to
// surface (task brief's own correctness rule).
// --- usage provenance (feat: expose token-accounting provenance) — unchanged from ProvidersView.vue ---
/** estimatedProvenance/unbilledProvenance pull p.provenance's two known kind keys (admin.go's buildAdminProvenanceViews emits only these two strings, never "reported") — undefined when that kind has no observations yet. */
function estimatedProvenance(p: AdminProviderView): AdminProvenanceView | undefined {
return p.provenance?.estimated
}
@@ -310,34 +204,48 @@ function unbilledProvenance(p: AdminProviderView): AdminProvenanceView | undefin
return p.provenance?.unbilled
}
/** estimatedProvenanceTitle is the estimated-accounting badge's hover/title detail. */
function estimatedProvenanceTitle(v: AdminProvenanceView): string {
const plural = v.requests === 1 ? '' : 's'
return `${v.requests} non-streaming response${plural} carried no usage from the provider — prompt tokens were estimated from request body size instead (${v.tokens} tokens billed from the estimate), completion billed as zero. ${perReplicaCaveat()}`
}
/** unbilledProvenanceTitle is the unbilled-accounting badge's hover/title detail — explicit about the budget-exposure implication (task brief's own framing). */
function unbilledProvenanceTitle(v: AdminProvenanceView): string {
const plural = v.requests === 1 ? '' : 's'
return `${v.requests} streaming response${plural} carried no usage from the provider — the request was counted but zero tokens were billed. If this keeps happening, this provider may be serving completions entirely free against every budget. ${perReplicaCaveat()}`
}
// --- model aliases (sortable DataTable, operator directive) ---
// --- fleet-wide performance (NEW: GET /admin/api/performance?kind=provider) ---
//
// The alias id gets the ModelChip copy treatment (an alias name IS the
// routable string a caller sends as `model`); the target column stays
// plain text.
// Fleet-wide (Redis-backed via the latency histogram counters, WP-A), UNLIKE
// every badge above this point in the file — a provider's own p50/p95
// here is the SAME reading regardless which replica answered the poll,
// the opposite of the per-replica caveat every latency/provenance badge
// above carries. timeouts/failovers are always populated (they do not
// need admin.stats.latency — only the percentile fields do, AdminPerfRow's
// own doc comment); a provider with zero of either renders no badge at
// all, mirroring every other "nothing to show" convention in this file.
function providerPerfFor(name: string): AdminPerfRow | undefined {
return models.providerPerf.find((r) => r.id === name)
}
function fleetLatencyTitle(kind: 'p50' | 'p95'): string {
const { window, span } = models.perfWindow
return `Fleet-wide (every replica combined) ${kind === 'p50' ? 'median' : '95th percentile'} response time, over the last ${span} ${window}${span === 1 ? '' : 's'}.`
}
function fleetCountTitle(kind: 'timeouts' | 'failovers'): string {
const { window, span } = models.perfWindow
const noun = kind === 'timeouts' ? 'requests that exceeded the deadline' : "requests failed away from this provider to another candidate"
return `${noun}, fleet-wide, over the last ${span} ${window}${span === 1 ? '' : 's'}.`
}
// --- model aliases (sortable DataTable) — unchanged from ProvidersView.vue ---
const aliasColumns: ColumnDef<AdminAliasView, unknown>[] = [
{
id: 'alias',
header: 'Alias',
accessorFn: (a) => a.alias,
// Optional-chained (review fix, folded minor) the same way
// modelMetaFor guards the provider path below: AdminAliasView.
// modelMeta is typed as always-present, but a real response from a
// server build older than this field (version skew) would otherwise
// throw on `.modelMeta.contextTokens` here instead of just omitting
// the hover detail.
cell: ({ row }) =>
h(ModelChip, {
id: row.original.alias,
@@ -425,12 +333,17 @@ const aliasEmptyMessage = computed(() =>
<CardHeader>
<CardTitle>Providers</CardTitle>
<CardDescription>
Every configured upstream and its last discovery refresh. Search filters providers and aliases by
model id.
Every configured upstream, its last discovery refresh, and fleet-wide performance. Search filters providers and
aliases by model id.
</CardDescription>
<SearchInput v-model="modelQuery" placeholder="Search models or aliases..." class="mt-2 max-w-sm" />
</CardHeader>
<CardContent>
<CardContent class="flex flex-col gap-3">
<Alert v-if="!models.latencyEnabled" variant="warn">
<AlertDescription>
Fleet p50/p95 are off for this deployment. Enable <code>admin.stats.latency</code> to see them.
</AlertDescription>
</Alert>
<p v-if="!overview?.providers.length" class="py-6 text-center text-sm text-muted-foreground">none</p>
<p
v-else-if="hasQuery && filteredProviders.length === 0"
@@ -439,31 +352,15 @@ const aliasEmptyMessage = computed(() =>
no providers match &quot;{{ modelQuery }}&quot;
</p>
<template v-else>
<!-- F4 (dashboard-plan.md): latency/provenance badges below are per-replica, in-process figures — this caption names which replica this poll answered from, up front, instead of leaving it to each badge's own hover title. -->
<p class="mb-2 text-xs text-muted-foreground">{{ perReplicaCaveat() }}</p>
<p class="text-xs text-muted-foreground">{{ perReplicaCaveat() }} Fleet p50/p95/timeouts/failovers below are fleet-wide, not per-replica.</p>
<Accordion v-model="expandedProviderValues" type="multiple" class="rounded-md border px-3">
<AccordionItem v-for="p in filteredProviders" :key="p.name" :value="p.name">
<!--
P2 review fix: the provider-name link moved OUTSIDE
AccordionTrigger — see goToProviderModels' own doc comment
above for why. `py-2.5` matches AccordionTrigger.vue's own
vertical padding so the two line up in this row.
`header-class="flex-1"` fills the AccordionHeader (<h3>) that
wraps the trigger button, since AccordionTrigger's own
`class` prop lands on the inner button, not the header — see
AccordionTrigger.vue's doc comment. Without it the header
shrinks to content width, so the chevron drifts left of the
row's right edge and the empty space to its right stops
being clickable. The sr-only span gives the toggle button an
accessible name that names the provider, since the visible
text beside it is just badges and counts.
-->
<div class="flex items-center gap-2">
<button
type="button"
class="shrink-0 cursor-pointer rounded-sm py-2.5 font-medium hover:text-primary hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/50"
:aria-label="`View ${p.name}'s models in Charts`"
:title="`View ${p.name}'s models in Charts`"
:aria-label="`View ${p.name}'s models in the Models table`"
:title="`View ${p.name}'s models in the Models table`"
@click="goToProviderModels(p.name)"
>{{ p.name }}</button>
<AccordionTrigger header-class="flex-1">
@@ -477,12 +374,6 @@ const aliasEmptyMessage = computed(() =>
:attempts-minute="p.attemptsMinute"
:failures-minute="p.failuresMinute"
/>
<!--
Discovery circuit breaker (feat/provider-health): no
badge at all while 'closed' (the healthy, common case),
mirroring modelIsDegraded's own convention for the
per-model rate badge above.
-->
<Badge
v-if="p.healthState !== 'closed'"
as="span"
@@ -492,15 +383,6 @@ const aliasEmptyMessage = computed(() =>
>
{{ healthBadgeLabel(p) }}
</Badge>
<!--
Upstream latency (feat: instrument upstream latency):
streaming's ttfb is the load-sensitive figure; the
non-streaming badge deliberately says "avg", never
"ttfb"/"latency" — see nonStreamingLatencyTitle's own
doc comment for why that number is not a load signal.
No badge at all for a stream state with no observations
yet, mirroring the health badge's own convention above.
-->
<Badge
v-if="streamingLatency(p)?.avgTtfbMs !== undefined"
as="span"
@@ -519,15 +401,42 @@ const aliasEmptyMessage = computed(() =>
>
non-streaming avg {{ formatLatencyMs(nonStreamingLatency(p)!.avgDurationMs as number) }}
</Badge>
<!--
Usage provenance (feat: expose token-accounting
provenance): no badge at all for a kind with no
observations yet, mirroring the health/latency badges'
own convention above. estimated and unbilled are two
distinct badges — never merged (correctness rule, task
brief) — since one substitutes an approximation and the
other bills nothing at all.
-->
<Badge
v-if="providerPerfFor(p.name)?.p50Ms !== undefined"
as="span"
variant="outline"
class="font-normal tabular-nums"
:title="fleetLatencyTitle('p50')"
>
fleet p50 {{ formatLatencyMs(providerPerfFor(p.name)!.p50Ms as number) }}
</Badge>
<Badge
v-if="providerPerfFor(p.name)?.p95Ms !== undefined"
as="span"
variant="outline"
class="font-normal tabular-nums"
:title="fleetLatencyTitle('p95')"
>
fleet p95 {{ formatLatencyMs(providerPerfFor(p.name)!.p95Ms as number) }}
</Badge>
<Badge
v-if="(providerPerfFor(p.name)?.timeouts ?? 0) > 0"
as="span"
variant="secondary"
class="font-normal tabular-nums"
:title="fleetCountTitle('timeouts')"
>
{{ providerPerfFor(p.name)!.timeouts }} timeouts
</Badge>
<Badge
v-if="(providerPerfFor(p.name)?.failovers ?? 0) > 0"
as="span"
variant="secondary"
class="font-normal tabular-nums"
:title="fleetCountTitle('failovers')"
>
{{ providerPerfFor(p.name)!.failovers }} failovers
</Badge>
<Badge
v-if="estimatedProvenance(p)"
as="span"
@@ -583,17 +492,7 @@ const aliasEmptyMessage = computed(() =>
:input-per-m-tok-usd="modelMetaFor(p, m).inputPerMTokUsd"
:output-per-m-tok-usd="modelMetaFor(p, m).outputPerMTokUsd"
/>
<!-- ScopeLink (F6): jumps to Charts, scoped to this exact model — renders here as a real <button> inside AccordionContent (not AccordionTrigger), same as the provider-name button above (P2 review fix moved both out of the trigger). -->
<ScopeLink :label="routableModelId(p.name, m)" :scope="`model:${routableModelId(p.name, m)}`" />
<!--
Context chip, visible, muted (feature v0.23) — only
when known. Cost stays hover-only on ModelChip itself
(operator directive: chips + hover would otherwise
show the identical price figures twice; context
visible for scanning, price on hover for the exact
numbers).
-->
<EntityLink :label="routableModelId(p.name, m)" kind="model" :id="routableModelId(p.name, m)" />
<span
v-if="modelMetaFor(p, m).contextTokens !== undefined"
title="context window"
@@ -601,14 +500,6 @@ const aliasEmptyMessage = computed(() =>
>
{{ formatContextWindow(modelMetaFor(p, m).contextTokens as number) }}
</span>
<!--
attempts-minute/failures-minute deliberately omitted
(SHOULD-2, v0.22 review round): AdminModelRateView no
longer carries them at all, so leaving them unbound
(undefined) is what tells ProviderRateBadge to render
its day-window-only detail (dayRateTitle) instead of
claiming a live minute-window reading no data backs.
-->
<ProviderRateBadge
v-if="modelIsDegraded(p, m)"
:attempts-day="modelRateFor(p, m).attemptsDay"
+8 -8
View File
@@ -5,10 +5,10 @@ import { Badge } from '@/components/ui/badge'
import { dayRateTitle, formatRatePercent, minuteRateTitle, providerRateStatus, providerSuccessRate } from '@/lib/provider-rate'
/**
* ProviderRateBadge is the Providers tab's success-rate badge (Feature A,
* v0.22) — reused for both a provider's own row (ProvidersView.vue's
* ProviderRateBadge is the Models page's success-rate badge (Feature A,
* v0.22) — reused for both a provider's own row (ProviderHealthPanel.vue's
* accordion trigger, always shown) and a single degraded model beside its
* ModelChip (ProvidersView.vue's accordion content, shown only when
* ModelChip (ProviderHealthPanel.vue's accordion content, shown only when
* isModelDegraded). attemptsMinute/failuresMinute are OPTIONAL (SHOULD-2,
* v0.22 review round): the provider-row call site passes them (admin.go's
* adminProviderView carries minute counters); the per-model call site
@@ -56,11 +56,11 @@ const detail = computed(() =>
// Badge has no dedicated "amber"/"muted" variant (ui/badge/index.ts) —
// severe reuses the built-in `destructive` variant exactly like every
// other error state in this panel (e.g. ProvidersView's own lastErr
// icon); healthy/no-traffic reuse `secondary`/`outline`, the panel's
// existing "quiet" idioms (ProvidersView's provider-type badge is
// `variant="secondary"`); degraded borrows the semantic --status-warn
// token (main.css — MUST-1, v0.22 review round: --chart-tokens-out, this
// other error state in this panel (e.g. ProviderHealthPanel.vue's own
// lastErr icon); healthy/no-traffic reuse `secondary`/`outline`, the
// panel's existing "quiet" idioms (ProviderHealthPanel.vue's
// provider-type badge is `variant="secondary"`); degraded borrows the
// semantic --status-warn token (main.css — MUST-1, v0.22 review round: --chart-tokens-out, this
// component's original choice, measures ~2.22:1 against --card in light
// mode, a WCAG 1.4.3 text-contrast failure on exactly the "something's
// wrong" tier; --status-warn is tuned for body-text contrast instead,
-69
View File
@@ -1,69 +0,0 @@
<script setup lang="ts">
import { faChartLine } from '@fortawesome/free-solid-svg-icons'
import { useNavStore } from '@/stores/nav'
/**
* ScopeLink (F6) is the one "jump to this scope's Charts view" control in
* this panel: usage-columns.ts's id cell (Users/Groups tables and the
* nested Members table), UsageView.vue's Groups accordion header, and
* WP-B2's ProvidersView.vue per-model chips all render one of these
* instead of each hand-rolling their own click-through-to-Charts wiring.
*
* A real `<button type="button">` (P2 review fix). This used to be a
* `<span role="link" tabindex="0">`, justified at the time by the Groups
* accordion trigger use site nesting it INSIDE shadcn-vue's
* AccordionTrigger — itself a real `<button>` (reka-ui's own
* AccordionTrigger primitive). That justification was wrong on its own
* terms: the HTML button content model forbids ANY descendant carrying a
* `tabindex` attribute, not just a nested button/anchor, and ARIA's
* `button` role additionally declares "children presentational: true", so
* assistive technology flattened the inner span and never exposed it as
* its own link at all — axe flags this as `nested-interactive`. Reka's
* AccordionTrigger also only listens for `click` (not `keydown`), so
* `stopPropagation()` on click and Enter suppressed the accordion's own
* toggle, but Space on the focused span did nothing and fell through to
* page scroll instead of activating anything.
*
* The actual fix is structural, not a markup trick: every call site now
* renders this component as a SIBLING of whatever it used to nest inside
* (UsageView.vue's Groups header, ProvidersView.vue's provider-name
* header — see both templates), never a descendant of another interactive
* element, so a real `<button>` is valid there. No `role` override: a
* plain button's implicit role is already correct here (activating it
* performs an action — a programmatic Pinia-store navigation, not a
* traditional `href` follow — which is exactly what a native `<button>`,
* not `<a>`, is for; WAI-ARIA's first rule of ARIA use is not to override
* a native element's semantics without a reason, and there is none left
* here). `stopPropagation` on click stays — harmless everywhere (a table
* cell, ProvidersView's per-model chips) and still correct defensively at
* the Groups/Providers headers, where this button sits next to, not
* inside, the accordion's own trigger.
*/
const props = defineProps<{
/** The visible + accessible label, e.g. a user id, group id, or model id. */
label: string
/** The scope string useNavStore.goToCharts expects — "total" | "user:{id}" | "group:{id}" | "model:{id}" (history.ts's own convention). */
scope: string
}>()
const nav = useNavStore()
function onClick(event: MouseEvent): void {
event.stopPropagation()
nav.goToCharts(props.scope)
}
</script>
<template>
<button
type="button"
:aria-label="`View ${label} in Charts`"
:title="`View ${label} in Charts`"
class="inline-flex cursor-pointer items-center gap-1 rounded-sm text-inherit hover:text-primary hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/50"
@click="onClick"
>
<span>{{ label }}</span>
<FontAwesomeIcon :icon="faChartLine" class="size-3" aria-hidden="true" />
</button>
</template>
+7 -5
View File
@@ -9,11 +9,13 @@ import { cn } from '@/lib/utils'
/**
* SearchInput is the one search-box control every filter in this panel
* uses — ProvidersView's model search, UsageView's user/group search, and
* ChartsView's scope-picker search each grew an identical ~22-line
* magnifying-glass-icon + Input + clear-button block; extracted into one
* component (vue.md: "if you've written it twice, you owe an
* abstraction"). A controlled component (modelValue/update:modelValue),
* uses (today: ProviderHealthPanel's model search, ConsumerDirectory's
* user/group search, TargetsView's target search, EventsView's user
* filter, ModelCatalogTable's model search) — each pre-redesign call site
* grew an identical ~22-line magnifying-glass-icon + Input + clear-button
* block on its own; extracted into one component (vue.md: "if you've
* written it twice, you owe an abstraction"). A controlled component
* (modelValue/update:modelValue),
* not v-model'd internally, since the incoming modelValue is a prop —
* callers pair it with `useSearchQuery()` (composables/useSearchQuery.ts)
* for the query/normalized/hasQuery/clear state, or v-model it against any
+40
View File
@@ -0,0 +1,40 @@
<script setup lang="ts">
import { PAGE_NAV } from '@/lib/pages'
import { useNavStore } from '@/stores/nav'
/**
* SidebarNav (redesign-plan.md section 3.1) is the shell's primary
* navigation: a vertical list of pages on a normal-width viewport,
* collapsing to a horizontally-scrollable bar on a narrow one (the
* `md:` breakpoint below) rather than a hamburger/drawer — every page is
* still one tap away, and the panel is an internal admin tool with no
* phone-first audience to justify hiding the list behind an extra tap.
*
* Real `<button>` elements, not `<a href>` (mirrors EntityLink.vue's own
* structural note): activating one is a programmatic Pinia navigation
* (nav.goTo), not a traditional link follow, and there is no separate
* URL to navigate to server-side — the hash is derived FROM this state
* (composables/useHashState.ts), not the other way around.
*/
const nav = useNavStore()
</script>
<template>
<nav
aria-label="Primary"
class="flex gap-1 overflow-x-auto border-b border-border pb-2 md:w-52 md:shrink-0 md:flex-col md:border-r md:border-b-0 md:pr-3 md:pb-0"
>
<button
v-for="entry in PAGE_NAV"
:key="entry.id"
type="button"
:aria-current="nav.page === entry.id ? 'page' : undefined"
class="flex shrink-0 items-center gap-2 rounded-lg px-3 py-2 text-sm font-medium whitespace-nowrap transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/50 md:w-full"
:class="nav.page === entry.id ? 'bg-accent text-accent-foreground' : 'text-muted-foreground hover:bg-muted hover:text-foreground'"
@click="nav.goTo(entry.id)"
>
<FontAwesomeIcon :icon="entry.icon" class="size-4" aria-hidden="true" />
<span>{{ entry.label }}</span>
</button>
</nav>
</template>
+60
View File
@@ -0,0 +1,60 @@
<script setup lang="ts">
import { faCheck, faCopy, faTriangleExclamation } from '@fortawesome/free-solid-svg-icons'
import { onUnmounted, ref, useTemplateRef } from 'vue'
import { Button } from '@/components/ui/button'
import { copyText } from '@/lib/clipboard'
/**
* SnippetBlock (redesign-plan.md section 3.4) is the one "here is text to
* paste into a file" surface every ChangeHelper form and ConfigTree.vue
* share — a `<pre>` plus a copy button (lib/clipboard.ts's copyText, same
* Clipboard-API-with-selection-fallback convention CsvExportButton.vue's
* download path and ModelChip.vue's own click-to-copy already use).
*/
const props = defineProps<{
/** The exact text to display and copy — YAML (lib/snippets.ts) or a single-line JSON fragment, verbatim, never re-formatted by this component. */
text: string
/** A short caption above the block, e.g. "Paste into middleware.yaml" or "Add to the user's users.json line". */
label?: string
}>()
const preRef = useTemplateRef<HTMLElement>('pre')
const state = ref<'idle' | 'copied' | 'selected' | 'failed'>('idle')
let resetTimer: ReturnType<typeof setTimeout> | undefined
async function onCopy(): Promise<void> {
state.value = await copyText(props.text, preRef.value ?? undefined)
clearTimeout(resetTimer)
resetTimer = setTimeout(() => {
state.value = 'idle'
}, 1500)
}
const BUTTON_LABEL: Record<typeof state.value, string> = {
idle: 'Copy',
copied: 'Copied',
selected: 'Selected — press ⌘/Ctrl+C',
failed: 'Copy failed',
}
onUnmounted(() => clearTimeout(resetTimer))
</script>
<template>
<div class="flex flex-col gap-1.5">
<div class="flex items-center justify-between gap-2">
<span v-if="label" class="text-xs font-medium text-muted-foreground">{{ label }}</span>
<Button type="button" variant="outline" size="sm" class="ml-auto" @click="onCopy">
<FontAwesomeIcon
:icon="state === 'idle' ? faCopy : state === 'failed' ? faTriangleExclamation : faCheck"
class="size-3.5"
:class="state === 'failed' ? 'text-destructive' : undefined"
aria-hidden="true"
/>
{{ BUTTON_LABEL[state] }}
</Button>
</div>
<pre ref="pre" class="overflow-x-auto rounded-md border bg-muted p-3 font-mono text-xs select-text whitespace-pre">{{ text }}</pre>
</div>
</template>
+3 -2
View File
@@ -7,8 +7,9 @@ import { computed } from 'vue'
/**
* SortHeaderButton is the one clickable, sortable-column control every
* sortable header in the panel uses — DataTable.vue's real `<th>` header
* buttons AND UsageView.vue's Groups sort toolbar (which has no `<table>`
* to put a `<th>` in — see that view's own doc comment for why).
* buttons AND ConsumerDirectory.vue's Groups sort toolbar (which has no
* `<table>` to put a `<th>` in — see that component's own "Groups
* accordion" doc comment for why).
* Extracted so the button markup, sort-icon logic, click handler, and
* accessible name exist in exactly one place (vue.md: "if you've written
* it twice, you owe an abstraction"). Extra classes on this component
+103
View File
@@ -0,0 +1,103 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import ClampedRangeNotice from '@/components/ClampedRangeNotice.vue'
import DataTable from '@/components/DataTable.vue'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { adminFetch } from '@/lib/api'
import { dayOrMonthWindow, totalsUrl } from '@/lib/range'
import { targetCallerColumns } from '@/lib/target-columns'
import { useFiltersStore } from '@/stores/filters'
import type { AdminTotalsResponse } from '@/types/api'
/**
* TargetCallers (redesign-plan.md section 3.4, MCP & Agents page) reads
* GET /admin/api/usage/totals?kind=targetcaller — per-caller request
* counts, ranked descending, dropped to zero rows server-side. Used in two
* modes from TargetsPage.vue, sharing this one component (vue.md: "if
* you've written it twice, you owe an abstraction") rather than a
* near-duplicate for each:
*
* - Per-target ("TargetCallers.vue expander"): `target` is set to the
* composed "mcp/{name}"/"agent/{name}" id (lib/target-columns.ts's
* composeTargetId) of whichever row the reader clicked in TargetsView.vue
* — shows only that target's own callers, no Target column (the panel's
* own heading already names it).
* - Fleet-wide ("top callers" card): `target` omitted — every configured
* target's callers, ranked together, WITH a Target column so a caller
* who reaches more than one target is not ambiguous.
*
* `kind=targetcaller` only supports day/month windows (redesign-plan.md
* section 1.3.v) — lib/range.ts's dayOrMonthWindow clamps the global
* filters store's current (possibly hour-window) range down to a window
* this endpoint actually accepts, rather than firing an invalid request
* whenever the reader has an hour-resolution range (24h/48h) selected.
*/
const props = withDefaults(
defineProps<{
/** Composed "mcp/{name}" / "agent/{name}" id — omitted means fleet-wide, every target. */
target?: string
title: string
limit?: number
}>(),
{ limit: 10 },
)
const filters = useFiltersStore()
const rows = ref<AdminTotalsResponse['rows']>([])
const loading = ref(false)
const loaded = ref(false)
const error = ref('')
/** reqId guards against an out-of-order response overwriting a newer one (latest-request-wins, stores/catalog.ts's own convention) — a quick target switch, or a range change mid-flight, no longer risks showing a STALE target's callers under the new heading. */
let reqId = 0
async function load(): Promise<void> {
const requestId = ++reqId
loading.value = true
try {
const { window, span } = dayOrMonthWindow(filters.window, filters.span)
const res = await adminFetch<AdminTotalsResponse>(
totalsUrl({ kind: 'targetcaller', window, span, metrics: ['req'], limit: props.limit, target: props.target }),
)
if (requestId !== reqId) return
rows.value = res.rows
error.value = ''
} catch (err) {
if (requestId !== reqId) return
error.value = err instanceof Error ? err.message : String(err)
} finally {
if (requestId === reqId) {
loading.value = false
loaded.value = true
}
}
}
watch(
[() => props.target, () => filters.window, () => filters.span],
() => void load(),
{ immediate: true },
)
const columns = computed(() => targetCallerColumns(!props.target))
const emptyMessage = computed(() => {
if (!loaded.value) return 'loading…'
return 'no callers recorded in this window'
})
</script>
<template>
<Card>
<CardHeader>
<CardTitle>{{ title }}</CardTitle>
<CardDescription>Ranked by requests, most recent day/month window available for the current range.</CardDescription>
</CardHeader>
<CardContent class="flex flex-col gap-3">
<ClampedRangeNotice :window="filters.window" :span="filters.span" />
<p v-if="error" class="text-sm text-destructive">{{ error }}</p>
<DataTable :columns="columns" :data="rows" :empty-message="emptyMessage" />
</CardContent>
</Card>
</template>
+43 -16
View File
@@ -5,20 +5,38 @@ import DataTable from '@/components/DataTable.vue'
import SearchInput from '@/components/SearchInput.vue'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { useSearchQuery } from '@/composables/useSearchQuery'
import { targetColumns } from '@/lib/target-columns'
import { composeTargetId, targetColumns } from '@/lib/target-columns'
import type { TargetKind } from '@/lib/target-columns'
import { useDashboardStore } from '@/stores/dashboard'
import type { AdminTargetView } from '@/types/api'
// This component backs the "MCP & Agents" tab (App.vue) — GET
// /admin/api/targets (admin.go: adminTargetsResponse, Feature B v0.21).
// This component backs the "MCP & Agents" page (redesign-plan.md section
// 3.4, TargetsPage.vue) — GET /admin/api/targets (admin.go:
// adminTargetsResponse, Feature B v0.21).
const dashboard = useDashboardStore()
const targets = computed(() => dashboard.targets)
/**
* select (redesign-plan.md section 3.4's "TargetCallers.vue expander")
* fires with the composed "mcp/{name}"/"agent/{name}" id and the target's
* own display name — TargetsPage.vue listens and renders TargetCallers.vue
* for whichever target the reader last clicked. Two separate
* targetColumns() instantiations below (MCP servers, Agents) close over
* their own fixed `kind` so a click always composes the correct prefix,
* without this component needing to carry a per-row kind field itself.
*/
const emit = defineEmits<{ select: [id: string, label: string] }>()
function onSelect(kind: TargetKind): (t: AdminTargetView) => void {
return (t) => emit('select', composeTargetId(kind, t.name), t.name)
}
// Simple, single-field substring match on name — no separate lib/*-search
// module: ProvidersView.vue's own aliasMatches/providerMatches stay inline
// for the identical reason (one field, one comparison), reserving the
// extracted-and-tested lib/usage-search.ts treatment for UsageView.vue's
// genuinely multi-entity (user-or-group-member) matching semantics.
// module: ProviderHealthPanel.vue's own aliasMatches/providerMatches stay
// inline for the identical reason (one field, one comparison), reserving
// the extracted-and-tested lib/usage-search.ts treatment for
// ConsumerDirectory.vue's genuinely multi-entity (user-or-group-member)
// matching semantics.
const { query, normalized: normalizedQuery, hasQuery } = useSearchQuery()
function nameMatches(t: AdminTargetView): boolean {
@@ -34,7 +52,7 @@ const filteredAgents = computed<AdminTargetView[]>(() => {
return hasQuery.value ? all.filter(nameMatches) : all
})
/** emptyMessage mirrors UsageView.vue's groupsEmptyMessage/usersEmptyMessage three-way pattern: distinguishes nothing configured from nothing matching the active query. */
/** emptyMessage mirrors ConsumerDirectory.vue's groupsEmptyMessage/usersEmptyMessage three-way pattern: distinguishes nothing configured from nothing matching the active query. */
function emptyMessage(configuredCount: number, filteredCount: number): string {
if (!configuredCount) return 'none configured'
if (hasQuery.value && filteredCount === 0) return `no targets match "${query.value}"`
@@ -44,10 +62,13 @@ function emptyMessage(configuredCount: number, filteredCount: number): string {
const mcpEmptyMessage = computed(() => emptyMessage(targets.value?.mcpServers.length ?? 0, filteredMCPServers.value.length))
const agentsEmptyMessage = computed(() => emptyMessage(targets.value?.agents.length ?? 0, filteredAgents.value.length))
// One shared column-def set (lib/target-columns.ts) for both tables below —
// stateless ColumnDef objects, safe to reuse across DataTable's two
// separate useVueTable instances (each owns its own sorting state).
const columns = targetColumns()
// Two separate column-def sets (lib/target-columns.ts) — MCP servers and
// Agents each close over their own fixed `kind` (see onSelect above) so a
// name click composes the right "mcp/…"/"agent/…" id. Stateless ColumnDef
// objects otherwise, safe to reuse across DataTable's two separate
// useVueTable instances (each owns its own sorting state).
const mcpColumns = targetColumns(onSelect('mcp'))
const agentColumns = targetColumns(onSelect('agent'))
</script>
<template>
@@ -57,20 +78,26 @@ const columns = targetColumns()
<Card>
<CardHeader>
<CardTitle>MCP servers</CardTitle>
<CardDescription>Configured MCP proxy targets, the groups allowed to reach them, and their request counters.</CardDescription>
<CardDescription>
Configured MCP proxy targets, the groups allowed to reach them, and their request counters. Click a name to see its
callers.
</CardDescription>
</CardHeader>
<CardContent>
<DataTable :columns="columns" :data="filteredMCPServers" :empty-message="mcpEmptyMessage" />
<DataTable :columns="mcpColumns" :data="filteredMCPServers" :empty-message="mcpEmptyMessage" />
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Agents</CardTitle>
<CardDescription>Configured A2A agent proxy targets, the groups allowed to reach them, and their request counters.</CardDescription>
<CardDescription>
Configured A2A agent proxy targets, the groups allowed to reach them, and their request counters. Click a name to
see its callers.
</CardDescription>
</CardHeader>
<CardContent>
<DataTable :columns="columns" :data="filteredAgents" :empty-message="agentsEmptyMessage" />
<DataTable :columns="agentColumns" :data="filteredAgents" :empty-message="agentsEmptyMessage" />
</CardContent>
</Card>
</div>
+183
View File
@@ -0,0 +1,183 @@
<script setup lang="ts">
import type { ChartData, ChartOptions } from 'chart.js'
import { computed, useId } from 'vue'
import { Line } from 'vue-chartjs'
import '@/lib/chart-setup'
import { useThemeColors } from '@/composables/useThemeColors'
import { formatExactInt } from '@/lib/format'
/** One line-chart series: a label, its values (index-aligned with the `labels` prop below), and which main.css --chart-* token colors it. */
export interface TimeSeriesDataset {
label: string
data: number[]
/** A main.css --chart-* custom property name (e.g. '--chart-requests') — resolved live via getComputedStyle (this component's own resolveToken, below), so a chart already on screen repaints on a `prefers-color-scheme` flip. */
color: string
}
/**
* TimeSeriesComparisonDataset is one `comparisonDatasets` entry — a plain
* TimeSeriesDataset plus an OPT-IN `labelSuffix`, appended to `label`
* verbatim (undefined/empty appends nothing). Callers decide per dataset,
* not this component for all of them: SpendPage.vue's own "vs previous
* period" comparison sets `labelSuffix: ' (previous period)'` (Q5), while
* BurnDownChart.vue's flat budget reference line — also rendered through
* this same `comparisonDatasets` prop, since a budget ceiling is exactly
* the "reference overlay, not a component of the current total" this prop
* already means — sets none, so its line reads plain "Budget", never the
* misleading "Budget (previous period)" (P2 item 5: a fixed
* budget line is not a previous-period ANYTHING).
*/
export interface TimeSeriesComparisonDataset extends TimeSeriesDataset {
labelSuffix?: string
}
/**
* TimeSeriesChart (redesign-plan.md section 3.4) is the redesign's shared
* line-chart primitive — Spend's cost breakdown (stacked), Reliability's
* per-provider error rate (overlaid, not stacked), and any other
* multi-series-over-time view build on this ONE component rather than
* each growing its own vue-chartjs wiring. It is the only chart component
* in this panel — lib/chart-setup.ts registers exactly the Line/Filler
* pieces it uses, nothing more.
*
* `comparisonDatasets` (Q5/section 3.1 "vs previous period", and
* BurnDownChart.vue's budget reference line) renders as a SEPARATE set of
* dashed lines, one per entry, matched to its solid counterpart by array
* index and color — never stacked, never filled, regardless of `stacked`,
* since a comparison line is a reference overlay, not a component of the
* current total.
*/
const props = withDefaults(
defineProps<{
labels: string[]
datasets: TimeSeriesDataset[]
/** A reference/overlay series, one dashed line per entry — omitted/empty draws no comparison. Each entry's own `labelSuffix` (TimeSeriesComparisonDataset) decides whether its label reads "vs previous period" or plain. */
comparisonDatasets?: TimeSeriesComparisonDataset[]
/** Stack every dataset into one filled total (Spend's cost breakdown) instead of overlaying them as independent lines (Reliability's per-provider error rate). */
stacked?: boolean
/** Formats a raw value for the tooltip and y-axis ticks — defaults to formatExactInt (lib/format.ts). Cost callers pass formatCost, a ratio series a percent formatter, and so on. */
valueFormatter?: (value: number) => string
/**
* ariaLabel is this chart's accessible name (P3 item 16) — a bare
* `<canvas>` has no text alternative at all for a screen reader, so
* every call site supplies a short description of what the chart
* shows (e.g. "Spend by model over time"). Required, not optional:
* there is no safe generic default that would be meaningful across
* this component's very different callers (Spend's breakdown,
* BurnDownChart's burn-down, Reliability's five series charts,
* UserDetail's two per-user charts).
*/
ariaLabel: string
}>(),
{
comparisonDatasets: () => [],
stacked: false,
valueFormatter: formatExactInt,
},
)
const descId = `timeseries-chart-desc-${useId()}`
/**
* srSummary is the sr-only fallback's own text content (P3 item 16's fix
* instruction: "an sr-only summary (latest value, total)") — one short
* sentence per dataset, latest bucket's value and the sum across the
* whole range, run through the SAME valueFormatter the visible tooltip/
* axis use, so a screen reader hears figures in the identical units a
* sighted reader sees. Comparison datasets are left out: they are a
* reference overlay of the SAME primary series shifted back a period,
* not new information this summary needs to repeat.
*/
const srSummary = computed<string>(() => {
if (props.datasets.length === 0 || props.labels.length === 0) return 'No data for this range.'
return props.datasets
.map((d) => {
const latest = d.data.length > 0 ? d.data[d.data.length - 1] : 0
const total = d.data.reduce((sum, v) => sum + v, 0)
return `${d.label}: latest ${props.valueFormatter(latest)}, total ${props.valueFormatter(total)} over ${props.labels.length} buckets.`
})
.join(' ')
})
const { foreground, mutedForeground, border } = useThemeColors()
/** resolveToken reads one of main.css's --chart-* custom properties from :root, live — see this component's own doc comment on `color` above. */
function resolveToken(cssVar: string): string {
return getComputedStyle(document.documentElement).getPropertyValue(cssVar).trim()
}
const chartData = computed<ChartData<'line'>>(() => {
// Touch the theme ref so this computed re-runs (and re-reads the CSS
// custom properties via resolveToken) on a `prefers-color-scheme` flip.
void foreground.value
const solid = props.datasets.map((d) => {
const color = resolveToken(d.color)
return {
label: d.label,
data: d.data,
borderColor: color,
backgroundColor: color,
fill: props.stacked,
stack: props.stacked ? 'main' : undefined,
tension: 0.2,
pointRadius: 2,
borderWidth: 2,
}
})
const comparison = props.comparisonDatasets.map((d) => {
const color = resolveToken(d.color)
return {
label: `${d.label}${d.labelSuffix ?? ''}`,
data: d.data,
borderColor: color,
backgroundColor: 'transparent',
borderDash: [6, 4],
fill: false,
tension: 0.2,
pointRadius: 0,
borderWidth: 1.5,
}
})
return { labels: props.labels, datasets: [...solid, ...comparison] }
})
const chartOptions = computed<ChartOptions<'line'>>(() => ({
responsive: true,
maintainAspectRatio: false,
animation: false,
interaction: { mode: 'index', intersect: false },
scales: {
x: {
stacked: props.stacked,
grid: { color: border.value, display: false },
ticks: { color: mutedForeground.value },
},
y: {
// Zero-based, honest axis (dataviz rule) — never a truncated y-scale that would exaggerate a small change.
stacked: props.stacked,
beginAtZero: true,
grid: { color: border.value },
ticks: { color: mutedForeground.value, callback: (value) => props.valueFormatter(Number(value)) },
},
},
plugins: {
legend: {
display: props.datasets.length > 1 || props.comparisonDatasets.length > 0,
labels: { color: foreground.value },
},
tooltip: {
callbacks: {
label: (ctx) => `${ctx.dataset.label}: ${props.valueFormatter(ctx.parsed.y ?? 0)}`,
},
},
},
}))
</script>
<template>
<div class="h-72 w-full" role="img" :aria-label="ariaLabel" :aria-describedby="descId">
<Line :data="chartData" :options="chartOptions" />
<p :id="descId" class="sr-only">{{ srSummary }}</p>
</div>
</template>
+50
View File
@@ -0,0 +1,50 @@
<script setup lang="ts">
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import EntityLink from '@/components/EntityLink.vue'
/** One TopList.vue row — an entity id/label, its formatted value, and which EntityLink.vue target it opens. */
export interface TopListItem {
/** The raw entity id EntityLink.vue navigates on (a user id or a canonical model id). */
id: string
/** The visible label — usually the same as `id`, but callers may pass a friendlier form. */
label: string
/** Already-formatted display value (lib/format.ts) — this component renders it verbatim, never reformats. */
value: string
kind: 'user' | 'model'
}
/**
* TopList (redesign-plan.md section 3.4) is the Home page's ranked-list
* card: "top 5 users by cost", "top 5 models". Each row is an EntityLink
* so clicking a user opens Consumers pre-filtered to them, a model opens
* Models — the SAME "jump to detail" affordance every table's id column
* (usage-columns.ts, model-table-columns.ts) offers, reused here rather
* than a plain unlinked list.
*/
defineProps<{
title: string
items: TopListItem[]
/** Shown in place of the list when `items` is empty — e.g. "no traffic in this range yet". */
emptyText: string
}>()
</script>
<template>
<Card>
<CardHeader>
<CardTitle>{{ title }}</CardTitle>
</CardHeader>
<CardContent>
<p v-if="items.length === 0" class="text-sm text-muted-foreground">{{ emptyText }}</p>
<ol v-else class="flex flex-col gap-1.5">
<li v-for="(item, i) in items" :key="item.id" class="flex items-center justify-between gap-3 text-sm">
<span class="flex min-w-0 items-center gap-2">
<span class="w-4 shrink-0 text-right text-xs text-muted-foreground">{{ i + 1 }}</span>
<EntityLink :label="item.label" :kind="item.kind" :id="item.id" class="min-w-0 truncate" />
</span>
<span class="shrink-0 font-medium tabular-nums">{{ item.value }}</span>
</li>
</ol>
</CardContent>
</Card>
</template>
+7 -6
View File
@@ -15,12 +15,13 @@ import { ratioTier } from '@/lib/usage-bars'
* `aria-valuetext`, so a screen reader announces the same number a
* sighted reader sees in the fill width and the `title` tooltip.
*
* Used two places (usage-columns.ts's numeric cells, UsageView.vue's
* group detail grid) — both pass a fully-computed BudgetRatio plus the
* caller's own human-readable `valueText`, so this component has no
* formatting opinion of its own (formatCost/formatExactInt stay the
* caller's job, same "logic lives in lib/, not the component" split
* every other shared component in this panel follows).
* Used across the panel (usage-columns.ts's numeric cells,
* ConsumerDirectory.vue's group detail grid, UserDetail.vue's headroom
* bars) — every caller passes a fully-computed BudgetRatio plus its own
* human-readable `valueText`, so this component has no formatting
* opinion of its own (formatCost/formatExactInt stay the caller's job,
* same "logic lives in lib/, not the component" split every other
* shared component in this panel follows).
*/
const props = defineProps<{
/** used / limit — may exceed 1 (a scope's usage window can roll past its limit before the next request is rejected). */
-153
View File
@@ -1,153 +0,0 @@
<script setup lang="ts">
import type { ChartData, ChartOptions } from 'chart.js'
import { computed } from 'vue'
import { Bar } from 'vue-chartjs'
import '@/lib/chart-setup'
import { useThemeColors } from '@/composables/useThemeColors'
import { formatBucketLabel, formatCompactCount, formatCost, formatExactInt } from '@/lib/format'
import type { TimeSeriesTab } from '@/stores/history'
import type { HistoryMetric, HistoryWindow, UsageHistoryPoint } from '@/types/api'
const props = defineProps<{
tab: TimeSeriesTab
window: HistoryWindow
seriesByMetric: Partial<Record<HistoryMetric, UsageHistoryPoint[]>>
}>()
const { foreground, mutedForeground, border } = useThemeColors()
/** One dataset spec per tab: which metric(s), its label, and its token color. */
const DATASET_SPECS: Record<TimeSeriesTab, { metric: HistoryMetric; label: string; color: string }[]> = {
requests: [{ metric: 'req', label: 'Requests', color: '--chart-requests' }],
tokens: [
{ metric: 'tokin', label: 'Tokens in', color: '--chart-tokens-in' },
{ metric: 'tokout', label: 'Tokens out', color: '--chart-tokens-out' },
],
cost: [{ metric: 'cost', label: 'Cost (USD)', color: '--chart-cost' }],
}
/** cost is stored as micro-USD (limits.go's account/history convention) — every other metric is already the display unit. */
function toDisplayValue(metric: HistoryMetric, raw: number): number {
return metric === 'cost' ? raw / 1_000_000 : raw
}
/**
* canonicalBuckets is the union of every active dataset's bucket keys,
* sorted. The "tokens" tab fetches tokin and tokout as two separate HTTP
* requests (history.ts's metricsForTab) — not one atomic call — so a
* window rollover landing between the two requests can leave one series
* one bucket ahead of the other. Zipping the two arrays positionally
* would silently mis-stack that refresh's chart. Bucket strings are
* fixed-width, zero-padded digit strings (limits.go's windowKey: hour
* "2006010215", day "20060102", month "200601"), so a plain lexicographic
* sort is also a chronological sort — no date parsing needed to align
* them.
*/
const canonicalBuckets = computed<string[]>(() => {
const specs = DATASET_SPECS[props.tab]
const bucketSet = new Set<string>()
for (const spec of specs) {
for (const p of props.seriesByMetric[spec.metric] ?? []) bucketSet.add(p.bucket)
}
return Array.from(bucketSet).sort()
})
const labels = computed<string[]>(() => canonicalBuckets.value.map((b) => formatBucketLabel(b, props.window)))
/** resolveToken reads one of main.css's --chart-* custom properties from :root, live — so a chart already on screen repaints on a `prefers-color-scheme` flip exactly like useThemeColors does for axis/legend text. */
function resolveToken(cssVar: string): string {
return getComputedStyle(document.documentElement).getPropertyValue(cssVar).trim()
}
const chartData = computed<ChartData<'bar'>>(() => {
// Touch the theme refs so this computed re-runs (and re-reads the CSS
// custom properties via resolveToken) on a `prefers-color-scheme` flip —
// resolveToken's own getComputedStyle read is not itself reactive.
void foreground.value
const buckets = canonicalBuckets.value
return {
labels: labels.value,
datasets: DATASET_SPECS[props.tab].map((spec) => {
// Map<bucket, value> per series, so each canonical bucket looks up
// its own value rather than assuming index i means the same bucket
// across every dataset (the mis-stacking canonicalBuckets exists to
// avoid — see its own doc comment). A bucket this series has no
// point for reads as 0, matching the zero-based axis: a genuinely
// missing bucket (not yet reported by the store) is indistinguishable
// from zero usage, which is the honest reading for a bar chart.
const byBucket = new Map((props.seriesByMetric[spec.metric] ?? []).map((p) => [p.bucket, p.value]))
return {
label: spec.label,
backgroundColor: resolveToken(spec.color),
data: buckets.map((b) => toDisplayValue(spec.metric, byBucket.get(b) ?? 0)),
}
}),
}
})
const stacked = computed<boolean>(() => DATASET_SPECS[props.tab].length > 1)
const chartOptions = computed<ChartOptions<'bar'>>(() => ({
responsive: true,
maintainAspectRatio: false,
animation: false,
scales: {
x: {
stacked: stacked.value,
grid: { color: border.value, display: false },
ticks: { color: mutedForeground.value },
},
y: {
// Zero-based, honest axis (dataviz rule): a bar chart's y-axis never
// starts anywhere but 0, so bar length always means what it looks
// like it means.
stacked: stacked.value,
beginAtZero: true,
grid: { color: border.value },
ticks: {
color: mutedForeground.value,
// Axis ticks stay compact (feature v0.23 addendum) — no room for
// the exact figure on an axis label; the tooltip below carries
// it. Cost reuses formatCost (the same formatter cost/day and
// cost/month already render through elsewhere in the app) rather
// than interpolating the raw tick float directly — Chart.js hands
// this callback whatever value it picked for gridline placement,
// which for a non-terminating division (e.g. cost/3) prints as
// "0.30000000000000004" without it. formatCost takes micro-USD, so
// the already-converted display value (toDisplayValue above) is
// scaled back up before the call — .toFixed(4) inside it then
// resolves any such float noise the same way the tooltip below
// already does by hand.
callback: (value) => (props.tab === 'cost' ? formatCost(Number(value) * 1_000_000) : formatCompactCount(Number(value))),
},
},
},
plugins: {
legend: {
display: stacked.value,
labels: { color: foreground.value },
},
tooltip: {
callbacks: {
// Tooltip shows the compact form WITH the precise raw value
// alongside (feature v0.23 addendum) — e.g. "Tokens in: 1.23M
// (1,234,567)" — since a tooltip, unlike an axis tick, has room
// for both and precision must never be lost. Cost stays its
// existing $-with-4-decimals format, unchanged.
label: (ctx) => {
const value = ctx.parsed.y ?? 0
if (props.tab === 'cost') return `${ctx.dataset.label}: $${value.toFixed(4)}`
return `${ctx.dataset.label}: ${formatCompactCount(value)} (${formatExactInt(value)})`
},
},
},
},
}))
</script>
<template>
<div class="h-72 w-full">
<Bar :data="chartData" :options="chartOptions" />
</div>
</template>
+6 -2
View File
@@ -24,13 +24,17 @@ const props = defineProps<{
emptyMessage?: string
/** P1: the shared reactive clock (composables/useNow.ts) — threaded into usageColumns() below so this table's own costMonthProj column recomputes as the clock ticks, instead of freezing at whatever instant this computed first ran. */
now: Date
/** P6: optional controlled sort state, forwarded to DataTable's own `v-model:sorting` (see that component's identical prop doc comment) — omitted everywhere except UsageView.vue's top-level Users table, whose export must reflect the table's current sort. */
/** P6: optional controlled sort state, forwarded to DataTable's own `v-model:sorting` (see that component's identical prop doc comment) — omitted everywhere except ConsumerDirectory.vue's top-level Users table, whose export must reflect the table's current sort. */
sorting?: SortingState
/** Optional per-row source lookup ('inline'/'file'/undefined), forwarded to usage-columns.ts's usageColumns — see that module's sourceColumn doc comment. Omitted everywhere except ConsumerDirectory.vue's flat Users table. */
sourceValue?: (entry: AdminUsageEntryView) => string | undefined
}>()
const emit = defineEmits<{ 'update:sorting': [value: SortingState] }>()
const columns = computed(() => usageColumns(props.idLabel, props.secondaryColumnLabel, props.secondaryValue, props.now))
const columns = computed(() =>
usageColumns(props.idLabel, props.secondaryColumnLabel, props.secondaryValue, props.now, props.sourceValue),
)
</script>
<template>
+274
View File
@@ -0,0 +1,274 @@
<script setup lang="ts">
import { faArrowLeft } from '@fortawesome/free-solid-svg-icons'
import { computed, onMounted, onUnmounted, watch } from 'vue'
import ClampedRangeNotice from '@/components/ClampedRangeNotice.vue'
import CompactNumber from '@/components/CompactNumber.vue'
import ModelChip from '@/components/ModelChip.vue'
import TimeSeriesChart from '@/components/TimeSeriesChart.vue'
import { Alert, AlertDescription } from '@/components/ui/alert'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
import UsageBar from '@/components/UsageBar.vue'
import { useNow } from '@/composables/useNow'
import { runOutDate as computeRunOutDate } from '@/lib/burndown'
import { formatBucketLabel, formatCost, formatExactInt, formatLimits, formatTimestamp } from '@/lib/format'
import { BUDGET_RATIO_LABEL, budgetRatios } from '@/lib/usage-bars'
import { useConsumersStore } from '@/stores/consumers'
import { useDashboardStore } from '@/stores/dashboard'
import { useEventsStore } from '@/stores/events'
import { useFiltersStore } from '@/stores/filters'
/**
* UserDetail (redesign-plan.md section 3.4) is the Consumers page's
* `user=<name>` drill-down: a timeline of the user's own req/cost series,
* their per-model breakdown (usermodel totals, when admin.stats.userModel
* is on), recent errors filtered from the shared Events store, headroom
* bars + an estimated run-out date, and their limits/grants.
*/
const props = defineProps<{ userId: string }>()
const emit = defineEmits<{ close: [] }>()
const dashboard = useDashboardStore()
const consumers = useConsumersStore()
const events = useEventsStore()
const filters = useFiltersStore()
const { now, start: startClock, stop: stopClock } = useNow()
onMounted(startClock)
onUnmounted(stopClock)
/** entry is this user's own row from the polled /admin/api/usage response — live counters, limits, lastSeen. undefined for a configured user who has never made a request (no usage scope created yet) or an id that does not exist at all. */
const entry = computed(() => dashboard.usage?.users.find((u) => u.id === props.userId))
/** consumerUser is this user's own row from GET /admin/api/consumers — source, admin flag, personal grant. */
const consumerUser = computed(() => consumers.data?.users.find((u) => u.name === props.userId))
const featuresUserModelStats = computed(() => dashboard.overview?.features.userModelStats ?? false)
function loadDetail(): void {
void consumers.fetchUserDetail(props.userId, filters.window, filters.span, featuresUserModelStats.value)
}
onMounted(() => {
loadDetail()
void events.refresh()
})
// featuresUserModelStats is included alongside userId/range: on a deep
// link or reload (#consumers?user=x), this component mounts and fires
// loadDetail() BEFORE the polled overview (stores/dashboard.ts) has
// necessarily landed its first response, so the very first loadDetail()
// call can run with userModelStatsEnabled still false even though the
// feature really is on. Once the overview lands and that computed flips
// true, this watch refetches — otherwise "Models used" stayed
// permanently empty for the rest of the session even after the alert
// telling the reader to enable it disappeared (P2).
watch([() => props.userId, () => filters.range, featuresUserModelStats], loadDetail)
const detail = computed(() => consumers.detail[props.userId])
// Labels are built from the RESPONSE's own `window` (AdminSeriesResponse),
// not from the current filters.window directly — two fetchUserDetail
// calls for different ranges can land out of order, and reading the
// window off the live global filter would then mislabel a still-
// displayed stale response's buckets (P1 item 7). Falls back to
// filters.window only before any response has ever landed.
const reqLabels = computed(() =>
(detail.value?.reqSeries?.buckets ?? []).map((b) => formatBucketLabel(b, detail.value?.reqSeries?.window ?? filters.window)),
)
const reqDatasets = computed(() => [
{ label: 'Requests', data: detail.value?.reqSeries?.series[0]?.points ?? [], color: '--chart-requests' },
])
const costLabels = computed(() =>
(detail.value?.costSeries?.buckets ?? []).map((b) => formatBucketLabel(b, detail.value?.costSeries?.window ?? filters.window)),
)
const costDatasets = computed(() => [
{ label: 'Cost', data: (detail.value?.costSeries?.series[0]?.points ?? []).map((v) => v), color: '--chart-cost' },
])
/** showUserModelHint mirrors AttributionDrilldown.vue's own convention (Spend page): the hint shows when the client's own feature flag reads off, OR the usermodel call itself came back 404 (stores/consumers.ts's modelTotalsUnavailable) — the second case catches a stale/not-yet-loaded overview on a deep link or reload, where featuresUserModelStats can read true for a moment while the server still says otherwise. */
const showUserModelHint = computed(() => !featuresUserModelStats.value || detail.value?.modelTotalsUnavailable === true)
/** modelRows ranks usermodel totals by cost desc (falling back to req when cost is absent from the requested metrics) — a small, page-local sort since this table has no other consumer that would justify a shared column/sort helper. */
const modelRows = computed(() => {
const rows = detail.value?.modelTotals?.rows ?? []
return [...rows].sort((a, b) => (b.values.cost ?? b.values.req ?? 0) - (a.values.cost ?? a.values.req ?? 0))
})
/** recentErrors filters the shared Events store (WP-F, read-only import) down to this user — the newest 10, matching the store's own newest-first order. */
const recentErrors = computed(() => events.events.filter((e) => e.user === props.userId).slice(0, 10))
const ratios = computed(() => (entry.value ? budgetRatios(entry.value) : []))
function budgetValueText(id: string, used: number, limit: number): string {
if (id.startsWith('cost')) return `${formatCost(used)} / ${formatCost(limit)}`
return `${formatExactInt(used)} / ${formatExactInt(limit)}`
}
/**
* runOutDate reuses lib/burndown.ts's own runOutDate — the SAME function
* BurnDownChart.vue projects the Spend page's burn-down line with — rather
* than a second, disagreeing implementation (P2 item 14: the two used to
* differ on an already-exceeded budget and on day 1 of the month).
*/
const runOutDate = computed(() => {
const limitUsd = entry.value?.limits?.costPerMonthUSD
if (!entry.value || !limitUsd) return null
return computeRunOutDate(entry.value.costPerMonthMicroUsd, Math.round(limitUsd * 1_000_000), now.value)
})
</script>
<template>
<div class="flex flex-col gap-4">
<div class="flex items-center gap-2">
<Button type="button" variant="ghost" size="sm" @click="emit('close')">
<FontAwesomeIcon :icon="faArrowLeft" class="size-3.5" aria-hidden="true" />
Back to directory
</Button>
</div>
<Card>
<CardHeader>
<div class="flex flex-wrap items-center gap-2">
<CardTitle class="font-mono">{{ userId }}</CardTitle>
<Badge v-if="consumerUser?.admin" variant="destructive">admin</Badge>
<Badge v-if="consumerUser?.source" variant="secondary">{{ consumerUser.source }}</Badge>
</div>
<CardDescription v-if="entry?.groups?.length || entry?.groupName">
Member of {{ (entry?.groups ?? [entry?.groupName]).filter(Boolean).join(', ') }}
</CardDescription>
</CardHeader>
<CardContent v-if="!entry" class="text-sm text-muted-foreground">
No usage scope for this user yet — it appears after their first request.
</CardContent>
<CardContent v-else class="grid grid-cols-2 gap-4 text-sm sm:grid-cols-4">
<div>
<p class="text-muted-foreground">Requests/day</p>
<p class="text-lg font-semibold tabular-nums"><CompactNumber :value="entry.requestsPerDay" /></p>
</div>
<div>
<p class="text-muted-foreground">Cost/day</p>
<p class="text-lg font-semibold tabular-nums">{{ formatCost(entry.costPerDayMicroUsd) }}</p>
</div>
<div>
<p class="text-muted-foreground">Cost/month</p>
<p class="text-lg font-semibold tabular-nums">{{ formatCost(entry.costPerMonthMicroUsd) }}</p>
</div>
<div>
<p class="text-muted-foreground">Limits</p>
<p class="text-sm">{{ formatLimits(entry.limits) }}</p>
</div>
</CardContent>
</Card>
<Card v-if="entry && ratios.length">
<CardHeader>
<CardTitle>Headroom</CardTitle>
<CardDescription v-if="runOutDate">At the current pace, cost/month runs out around {{ formatTimestamp(runOutDate.toISOString()) }}.</CardDescription>
</CardHeader>
<CardContent class="flex flex-wrap items-center gap-x-4 gap-y-1.5">
<span v-for="ratio in ratios" :key="ratio.id" class="inline-flex items-center gap-1.5 text-xs text-muted-foreground">
{{ BUDGET_RATIO_LABEL[ratio.id] }}
<UsageBar
:ratio="ratio.ratio"
:label="BUDGET_RATIO_LABEL[ratio.id]"
:value-text="budgetValueText(ratio.id, ratio.used, ratio.limit)"
/>
</span>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Requests over time</CardTitle>
</CardHeader>
<CardContent>
<p v-if="detail?.loading" class="text-sm text-muted-foreground">loading…</p>
<p v-else-if="detail?.error" class="text-sm text-destructive">{{ detail.error }}</p>
<TimeSeriesChart v-else :labels="reqLabels" :datasets="reqDatasets" :ariaLabel="`Requests over time for ${userId}`" />
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Cost over time</CardTitle>
</CardHeader>
<CardContent>
<p v-if="detail?.loading" class="text-sm text-muted-foreground">loading…</p>
<p v-else-if="detail?.error" class="text-sm text-destructive">{{ detail.error }}</p>
<TimeSeriesChart v-else :labels="costLabels" :datasets="costDatasets" :value-formatter="formatCost" :ariaLabel="`Cost over time for ${userId}`" />
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Models used</CardTitle>
</CardHeader>
<CardContent class="flex flex-col gap-3">
<ClampedRangeNotice :window="filters.window" :span="filters.span" />
<Alert v-if="showUserModelHint" variant="warn">
<AlertDescription>Enable admin.stats.userModel in the plugin config to see a per-model breakdown for this user.</AlertDescription>
</Alert>
<Table v-else-if="modelRows.length">
<TableHeader>
<TableRow>
<TableHead>Model</TableHead>
<TableHead class="text-right">Requests</TableHead>
<TableHead class="text-right">Cost</TableHead>
</TableRow>
</TableHeader>
<TableBody>
<TableRow v-for="row in modelRows" :key="row.id">
<TableCell><ModelChip :id="row.id" /></TableCell>
<TableCell class="text-right tabular-nums"><CompactNumber :value="row.values.req ?? 0" /></TableCell>
<TableCell class="text-right tabular-nums">{{ formatCost(row.values.cost ?? 0) }}</TableCell>
</TableRow>
</TableBody>
</Table>
<p v-else class="py-6 text-center text-sm text-muted-foreground">no model usage in this range</p>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Recent errors</CardTitle>
</CardHeader>
<CardContent>
<ul v-if="recentErrors.length" class="flex flex-col gap-2 text-sm">
<li v-for="(event, index) in recentErrors" :key="index" class="border-b pb-2 last:border-0 last:pb-0">
<p class="flex flex-wrap items-center gap-2">
<Badge variant="secondary">{{ event.kind }}</Badge>
<span class="text-muted-foreground">{{ formatTimestamp(event.time) }}</span>
</p>
<p class="text-muted-foreground">{{ event.message }}</p>
</li>
</ul>
<p v-else class="py-6 text-center text-sm text-muted-foreground">none in the current event window</p>
</CardContent>
</Card>
<Card v-if="consumerUser?.providers?.length || consumerUser?.models?.length">
<CardHeader>
<CardTitle>Personal grant</CardTitle>
<CardDescription>Access this user has beyond their group's own.</CardDescription>
</CardHeader>
<CardContent class="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div>
<p class="mb-1.5 text-xs text-muted-foreground">Providers</p>
<div class="flex flex-wrap gap-1.5">
<Badge v-for="name in consumerUser?.providers ?? []" :key="name" as="span" variant="secondary" class="font-mono font-normal">
{{ name }}
</Badge>
</div>
</div>
<div>
<p class="mb-1.5 text-xs text-muted-foreground">Models</p>
<div class="flex flex-wrap gap-1.5">
<ModelChip v-for="id in consumerUser?.models ?? []" :key="id" :id="id" />
</div>
</div>
</CardContent>
</Card>
</div>
</template>
+138
View File
@@ -0,0 +1,138 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import SnippetBlock from '@/components/SnippetBlock.vue'
import { Alert, AlertDescription } from '@/components/ui/alert'
import { Input } from '@/components/ui/input'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { groupGrantSnippet, isValidName, isValidUserName, userGrantJsonFragment, validatePersonalGrant } from '@/lib/snippets'
/**
* GrantForm (ChangeHelper, redesign-plan.md section 3.4) builds a group's
* access-list snippet (providers/models/mcpServers/agents, YAML) or a
* user's personal grant (providers/models only — GroupConfig has no
* per-user mcpServers/agents grant field, auth.go's own UserConfig shape)
* as a bare JSON fragment for an existing users.json entry.
*
* Every list field is a comma-separated text Input rather than a proper
* multi-select (no such shadcn-vue primitive is installed in this panel) —
* parsed, trimmed, and de-duplicated below.
*/
const props = defineProps<{
groupNames: string[]
userNames: string[]
}>()
const targetKind = ref<'group' | 'user'>('group')
const targetName = ref('')
const providersRaw = ref('')
const modelsRaw = ref('')
const mcpServersRaw = ref('')
const agentsRaw = ref('')
/** parseList splits a comma-separated field into trimmed, non-empty, de-duplicated entries — the one shared parser every list field below uses. */
function parseList(raw: string): string[] {
const seen = new Set<string>()
for (const part of raw.split(',')) {
const trimmed = part.trim()
if (trimmed) seen.add(trimmed)
}
return Array.from(seen)
}
const providers = computed(() => parseList(providersRaw.value))
const models = computed(() => parseList(modelsRaw.value))
const mcpServers = computed(() => parseList(mcpServersRaw.value))
const agents = computed(() => parseList(agentsRaw.value))
const targetNames = computed(() => (targetKind.value === 'group' ? props.groupNames : props.userNames))
// A group name follows NAME_PATTERN (auth.go's configNamePattern — a
// route-path-segment safety rule); a user name only needs to be
// non-empty (auth.go's buildEntry, no character restriction at all —
// P3 item 27). isValidUserName, not isValidName, for targetKind === 'user'.
const nameValid = computed(() => (targetKind.value === 'group' ? isValidName(targetName.value.trim()) : isValidUserName(targetName.value)))
// validatePersonalGrant is a USER-ONLY rule (lib/snippets.ts's own doc
// comment) — a group's own `providers: []` legitimately means "every
// provider" and Go applies it with no such check at group-construction
// time (auth.go). Gated on targetKind so a group grant with an empty
// providers list + a models list (a perfectly valid group config) no
// longer shows a rejection that does not apply to it (P1 item 13).
const grantError = computed(() => (targetKind.value === 'user' ? validatePersonalGrant(providers.value, models.value) : null))
const isEmpty = computed(
() => providers.value.length === 0 && models.value.length === 0 && mcpServers.value.length === 0 && agents.value.length === 0,
)
const snippet = computed<string | null>(() => {
if (!nameValid.value || grantError.value || isEmpty.value) return null
if (targetKind.value === 'group') {
return groupGrantSnippet(targetName.value.trim(), {
providers: providers.value,
models: models.value,
mcpServers: mcpServers.value,
agents: agents.value,
})
}
const fragment = userGrantJsonFragment({ providers: providers.value, models: models.value })
return fragment || null
})
</script>
<template>
<div class="flex flex-col gap-4">
<div class="grid grid-cols-1 gap-3 sm:grid-cols-2">
<div class="flex flex-col gap-1">
<label id="grant-target-kind-label" class="text-xs font-medium text-muted-foreground">Target</label>
<Select :model-value="targetKind" @update:model-value="(v) => (targetKind = v as 'group' | 'user')">
<SelectTrigger aria-labelledby="grant-target-kind-label"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="group">Group</SelectItem>
<SelectItem value="user">User (personal grant, existing users.json entry)</SelectItem>
</SelectContent>
</Select>
</div>
<div class="flex flex-col gap-1">
<label id="grant-target-name-label" class="text-xs font-medium text-muted-foreground">{{ targetKind === 'group' ? 'Group name' : 'User name' }}</label>
<Select :model-value="targetName" @update:model-value="(v) => (targetName = String(v))">
<SelectTrigger aria-labelledby="grant-target-name-label"><SelectValue placeholder="Select…" /></SelectTrigger>
<SelectContent>
<SelectItem v-for="name in targetNames" :key="name" :value="name">{{ name }}</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<div class="grid grid-cols-1 gap-3 sm:grid-cols-2">
<div class="flex flex-col gap-1">
<label for="grant-providers" class="text-xs font-medium text-muted-foreground">Providers (comma-separated, empty = all)</label>
<Input id="grant-providers" v-model="providersRaw" placeholder="anthropic, openai" />
</div>
<div class="flex flex-col gap-1">
<label for="grant-models" class="text-xs font-medium text-muted-foreground">Models (comma-separated, empty = all)</label>
<Input id="grant-models" v-model="modelsRaw" placeholder="anthropic/claude-sonnet-5" />
</div>
<template v-if="targetKind === 'group'">
<div class="flex flex-col gap-1">
<label for="grant-mcp" class="text-xs font-medium text-muted-foreground">MCP servers (comma-separated, empty = all)</label>
<Input id="grant-mcp" v-model="mcpServersRaw" placeholder="fetch, weather" />
</div>
<div class="flex flex-col gap-1">
<label for="grant-agents" class="text-xs font-medium text-muted-foreground">Agents (comma-separated, empty = all)</label>
<Input id="grant-agents" v-model="agentsRaw" placeholder="agentkit" />
</div>
</template>
</div>
<Alert v-if="grantError" variant="destructive">
<AlertDescription>{{ grantError }}</AlertDescription>
</Alert>
<SnippetBlock
v-if="snippet"
:label="targetKind === 'group' ? 'Paste into middleware.yaml' : `Merge into ${targetName}’s users.json line`"
:text="snippet"
/>
<p v-else class="text-sm text-muted-foreground">Pick a target and at least one list to build a snippet.</p>
</div>
</template>
+143
View File
@@ -0,0 +1,143 @@
<script setup lang="ts">
import { computed, reactive, ref } from 'vue'
import SnippetBlock from '@/components/SnippetBlock.vue'
import { Alert, AlertDescription } from '@/components/ui/alert'
import { Input } from '@/components/ui/input'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { groupLimitsSnippet, isValidName, isValidNonNegative, isValidNonNegativeInt, isValidUserName, userLimitsJsonFragment } from '@/lib/snippets'
import type { LimitsConfig } from '@/types/api'
/**
* LimitsForm (ChangeHelper, redesign-plan.md section 3.4) builds a
* group's or a user's limits snippet — a group's is a full YAML block
* (lib/snippets.ts's groupLimitsSnippet); a user's own limits live inside
* their users.json line, so it is a bare JSON field fragment
* (userLimitsJsonFragment) meant to be merged into that user's existing
* entry, not a whole new line (UserForm.vue owns building a whole new
* line).
*/
const props = defineProps<{
groupNames: string[]
userNames: string[]
}>()
const targetKind = ref<'group' | 'user'>('group')
const targetName = ref('')
/** INTEGER_FIELDS is every LimitsConfig field Go decodes as `int64` (llmgateway.go) — requestsPerMinute/requestsPerDay/tokensPerDay/tokensPerMonth reject a fractional value like "1.5" at json.Unmarshal outright (P2 item 12), unlike costPerDayUSD/costPerMonthUSD, which are `float64`. */
const INTEGER_FIELDS = new Set<keyof LimitsConfig>(['requestsPerMinute', 'requestsPerDay', 'tokensPerDay', 'tokensPerMonth'])
/** fieldValid applies isValidNonNegativeInt to the four int64 fields and isValidNonNegative (fractions allowed) to the two float64 cost fields — the ONE gate both invalidFields and limits below share, so they can never disagree on which fields are int-only. */
function fieldValid(key: keyof LimitsConfig, value: number): boolean {
return INTEGER_FIELDS.has(key) ? isValidNonNegativeInt(value) : isValidNonNegative(value)
}
const FIELDS: { key: keyof LimitsConfig; label: string }[] = [
{ key: 'requestsPerMinute', label: 'Requests/minute' },
{ key: 'requestsPerDay', label: 'Requests/day' },
{ key: 'tokensPerDay', label: 'Tokens/day (in+out)' },
{ key: 'tokensPerMonth', label: 'Tokens/month (in+out)' },
{ key: 'costPerDayUSD', label: 'Cost/day (USD)' },
{ key: 'costPerMonthUSD', label: 'Cost/month (USD)' },
]
const raw = reactive<Record<keyof LimitsConfig, string>>({
requestsPerMinute: '',
requestsPerDay: '',
tokensPerDay: '',
tokensPerMonth: '',
costPerDayUSD: '',
costPerMonthUSD: '',
})
const targetNames = computed(() => (targetKind.value === 'group' ? props.groupNames : props.userNames))
/** invalidFields lists every field whose entered text is non-empty but fails its own domain check (fieldValid above) — surfaced individually rather than one generic "invalid input" message, so the operator can see exactly which field to fix. Split by INTEGER_FIELDS so the error text is accurate for both: a fraction is invalid for requestsPerMinute etc. but perfectly valid for costPerDayUSD/costPerMonthUSD. */
const invalidFields = computed(() =>
FIELDS.filter(({ key }) => raw[key].trim() !== '' && !fieldValid(key, Number(raw[key]))).map((f) => f.label),
)
const invalidIntegerFields = computed(() =>
FIELDS.filter(({ key }) => INTEGER_FIELDS.has(key) && raw[key].trim() !== '' && !fieldValid(key, Number(raw[key]))).map((f) => f.label),
)
const invalidDecimalFields = computed(() =>
FIELDS.filter(({ key }) => !INTEGER_FIELDS.has(key) && raw[key].trim() !== '' && !fieldValid(key, Number(raw[key]))).map((f) => f.label),
)
const limits = computed<LimitsConfig>(() => {
const out: LimitsConfig = {}
for (const { key } of FIELDS) {
const value = Number(raw[key])
if (raw[key].trim() !== '' && fieldValid(key, value) && value > 0) out[key] = value
}
return out
})
// A group name follows NAME_PATTERN (auth.go's configNamePattern — a
// route-path-segment safety rule); a user name only needs to be
// non-empty (auth.go's buildEntry, no character restriction at all —
// P3 item 27). isValidUserName, not isValidName, for targetKind === 'user'.
const nameValid = computed(() => (targetKind.value === 'group' ? isValidName(targetName.value.trim()) : isValidUserName(targetName.value)))
const snippet = computed<string | null>(() => {
if (!nameValid.value || invalidFields.value.length > 0) return null
if (Object.keys(limits.value).length === 0) return null
return targetKind.value === 'group'
? groupLimitsSnippet(targetName.value.trim(), limits.value)
: userLimitsJsonFragment(limits.value)
})
</script>
<template>
<div class="flex flex-col gap-4">
<div class="grid grid-cols-1 gap-3 sm:grid-cols-2">
<div class="flex flex-col gap-1">
<label id="limits-target-kind-label" class="text-xs font-medium text-muted-foreground">Target</label>
<Select :model-value="targetKind" @update:model-value="(v) => (targetKind = v as 'group' | 'user')">
<SelectTrigger aria-labelledby="limits-target-kind-label"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="group">Group</SelectItem>
<SelectItem value="user">User (existing users.json entry)</SelectItem>
</SelectContent>
</Select>
</div>
<div class="flex flex-col gap-1">
<label id="limits-target-name-label" class="text-xs font-medium text-muted-foreground">{{ targetKind === 'group' ? 'Group name' : 'User name' }}</label>
<Select :model-value="targetName" @update:model-value="(v) => (targetName = String(v))">
<SelectTrigger aria-labelledby="limits-target-name-label"><SelectValue placeholder="Select…" /></SelectTrigger>
<SelectContent>
<SelectItem v-for="name in targetNames" :key="name" :value="name">{{ name }}</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<div class="grid grid-cols-1 gap-3 sm:grid-cols-3">
<div v-for="field in FIELDS" :key="field.key" class="flex flex-col gap-1">
<label :for="`limits-${field.key}`" class="text-xs font-medium text-muted-foreground">{{ field.label }}</label>
<Input
:id="`limits-${field.key}`"
v-model="raw[field.key]"
type="number"
min="0"
:step="INTEGER_FIELDS.has(field.key) ? '1' : 'any'"
placeholder="unset"
/>
</div>
</div>
<Alert v-if="invalidFields.length" variant="destructive">
<AlertDescription>
<p v-if="invalidIntegerFields.length">Enter a non-negative whole number for: {{ invalidIntegerFields.join(', ') }}.</p>
<p v-if="invalidDecimalFields.length">Enter a non-negative number for: {{ invalidDecimalFields.join(', ') }}.</p>
</AlertDescription>
</Alert>
<SnippetBlock
v-if="snippet"
:label="targetKind === 'group' ? 'Paste into middleware.yaml' : `Merge into ${targetName}’s users.json line`"
:text="snippet"
/>
<p v-else class="text-sm text-muted-foreground">Pick a target and at least one limit to build a snippet.</p>
</div>
</template>
@@ -0,0 +1,64 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import SnippetBlock from '@/components/SnippetBlock.vue'
import { Alert, AlertDescription } from '@/components/ui/alert'
import { Input } from '@/components/ui/input'
import { isValidNonNegativeInt, modelMetaSnippet } from '@/lib/snippets'
/**
* ModelMetaForm (ChangeHelper, redesign-plan.md section 3.4) builds a
* model's contextTokens/free modelMeta entry — independent fields (unlike
* PricingForm.vue's paired input/output price), either or both may be
* set: contextTokens alone documents a window the upstream cannot report;
* free alone marks a self-hosted model billed as 0 regardless of any
* discovered/override price (pricing.go: modelMetaFree short-circuits
* before override/builtin/LiteLLM resolution).
*/
const modelId = ref('')
const contextRaw = ref('')
const free = ref(false)
const modelIdValid = computed(() => modelId.value.trim() !== '')
const contextValue = computed(() => Number(contextRaw.value))
const contextEntered = computed(() => contextRaw.value.trim() !== '')
// ModelMetaConfig.ContextTokens is Go `int` (llmgateway.go) — a fraction
// like "128000.5" fails json.Unmarshal outright, so isValidNonNegativeInt
// (not isValidNonNegative) is the right domain check here (P2 item 12).
const contextValid = computed(() => !contextEntered.value || (isValidNonNegativeInt(contextValue.value) && contextValue.value > 0))
const snippet = computed<string | null>(() => {
if (!modelIdValid.value || !contextValid.value) return null
if (!contextEntered.value && !free.value) return null
return modelMetaSnippet(modelId.value.trim(), {
contextTokens: contextEntered.value ? contextValue.value : undefined,
free: free.value || undefined,
})
})
</script>
<template>
<div class="flex flex-col gap-4">
<div class="flex flex-col gap-1">
<label for="modelmeta-model-id" class="text-xs font-medium text-muted-foreground">Model id (provider/model)</label>
<Input id="modelmeta-model-id" v-model="modelId" placeholder="gx10/current" class="font-mono" />
</div>
<div class="grid grid-cols-1 gap-3 sm:grid-cols-2 sm:items-end">
<div class="flex flex-col gap-1">
<label for="modelmeta-context" class="text-xs font-medium text-muted-foreground">Context window (tokens)</label>
<Input id="modelmeta-context" v-model="contextRaw" type="number" min="1" step="1" placeholder="131072" />
</div>
<label class="flex items-center gap-2 pb-1.5 text-sm">
<input id="modelmeta-free" v-model="free" type="checkbox" class="size-4 rounded border-input" />
Free (billed as $0 regardless of any resolved price)
</label>
</div>
<Alert v-if="contextEntered && !contextValid" variant="destructive">
<AlertDescription>Context window must be a positive whole number of tokens.</AlertDescription>
</Alert>
<SnippetBlock v-if="snippet" label="Paste into middleware.yaml" :text="snippet" />
<p v-else class="text-sm text-muted-foreground">Enter a model id and set a context window and/or free to build a snippet.</p>
</div>
</template>
@@ -0,0 +1,74 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import SnippetBlock from '@/components/SnippetBlock.vue'
import { Alert, AlertDescription } from '@/components/ui/alert'
import { Input } from '@/components/ui/input'
import { isValidNonNegative, pricingSnippet } from '@/lib/snippets'
/**
* PricingForm (ChangeHelper, redesign-plan.md section 3.4) builds a
* model's per-token pricing override — inputPerM/outputPerM, the two
* ModelPricing fields (llmgateway.go) that live in the top-level
* Config.Pricing map (`spec.plugin.llmgateway.pricing`) and OVERRIDE the
* built-in/LiteLLM-derived price for one model id (pricing.go's
* billingPriceSource: `override` always wins). This is a DIFFERENT config
* section from ModelMetaForm.vue's own modelMeta entry — ModelMeta's own
* cost fields drive metadata exposure only, never billing (see
* lib/snippets.ts's own ModelMeta doc comment). Both fields are required
* together — a one-sided price makes no billing sense — unlike
* ModelMetaForm.vue's independent contextTokens/free fields.
*/
const modelId = ref('')
const inputRaw = ref('')
const outputRaw = ref('')
const modelIdValid = computed(() => modelId.value.trim() !== '')
const inputValue = computed(() => Number(inputRaw.value))
const outputValue = computed(() => Number(outputRaw.value))
const inputValid = computed(() => inputRaw.value.trim() !== '' && isValidNonNegative(inputValue.value))
const outputValid = computed(() => outputRaw.value.trim() !== '' && isValidNonNegative(outputValue.value))
const errors = computed(() => {
const out: string[] = []
if (inputRaw.value.trim() !== '' && !inputValid.value) out.push('Input price/MTok must be a non-negative number.')
if (outputRaw.value.trim() !== '' && !outputValid.value) out.push('Output price/MTok must be a non-negative number.')
if ((inputValid.value && !outputValid.value) || (!inputValid.value && outputValid.value)) {
out.push('Set both input and output price/MTok — a one-sided override has no billing meaning.')
}
return out
})
const snippet = computed<string | null>(() => {
if (!modelIdValid.value || !inputValid.value || !outputValid.value) return null
return pricingSnippet(modelId.value.trim(), { inputPerM: inputValue.value, outputPerM: outputValue.value })
})
</script>
<template>
<div class="flex flex-col gap-4">
<div class="flex flex-col gap-1">
<label for="pricing-model-id" class="text-xs font-medium text-muted-foreground">Model id (provider/model)</label>
<Input id="pricing-model-id" v-model="modelId" placeholder="openai/gpt-4" class="font-mono" />
</div>
<div class="grid grid-cols-1 gap-3 sm:grid-cols-2">
<div class="flex flex-col gap-1">
<label for="pricing-input" class="text-xs font-medium text-muted-foreground">Input price / MTok (USD)</label>
<Input id="pricing-input" v-model="inputRaw" type="number" min="0" step="any" placeholder="0.50" />
</div>
<div class="flex flex-col gap-1">
<label for="pricing-output" class="text-xs font-medium text-muted-foreground">Output price / MTok (USD)</label>
<Input id="pricing-output" v-model="outputRaw" type="number" min="0" step="any" placeholder="1.50" />
</div>
</div>
<Alert v-if="errors.length" variant="destructive">
<AlertDescription>
<p v-for="(error, index) in errors" :key="index">{{ error }}</p>
</AlertDescription>
</Alert>
<SnippetBlock v-if="snippet" label="Paste into middleware.yaml" :text="snippet" />
<p v-else class="text-sm text-muted-foreground">Enter a model id and both prices to build a snippet.</p>
</div>
</template>
+123
View File
@@ -0,0 +1,123 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import SnippetBlock from '@/components/SnippetBlock.vue'
import { Alert, AlertDescription } from '@/components/ui/alert'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { API_KEY_PLACEHOLDER, generateApiKey, isValidUserName, userJsonLine, validatePersonalGrant } from '@/lib/snippets'
/**
* UserForm (ChangeHelper, redesign-plan.md section 3.4) builds a whole new
* users.json line — name, apiKey (placeholder by default, Q10 DECISION;
* "Generate" produces a real client-side random key, never sent anywhere),
* a primary group (production's own single-`group` convention) plus an
* optional comma list of ADDITIONAL groups that switches the emitted field
* to plural `groups` (lib/snippets.ts's userJsonLine), an optional
* personal grant (validated the same way GrantForm.vue's user-target path
* is), and an admin checkbox.
*/
const props = defineProps<{ groupNames: string[] }>()
const name = ref('')
const apiKey = ref(API_KEY_PLACEHOLDER)
const primaryGroup = ref('')
const extraGroupsRaw = ref('')
const providersRaw = ref('')
const modelsRaw = ref('')
const admin = ref(false)
function generate(): void {
apiKey.value = generateApiKey()
}
function parseList(raw: string): string[] {
const seen = new Set<string>()
for (const part of raw.split(',')) {
const trimmed = part.trim()
if (trimmed) seen.add(trimmed)
}
return Array.from(seen)
}
const extraGroups = computed(() => parseList(extraGroupsRaw.value))
const providers = computed(() => parseList(providersRaw.value))
const models = computed(() => parseList(modelsRaw.value))
const nameValid = computed(() => isValidUserName(name.value))
const groupValid = computed(() => primaryGroup.value !== '')
const apiKeyValid = computed(() => apiKey.value.trim() !== '' && apiKey.value !== API_KEY_PLACEHOLDER)
const grantError = computed(() => validatePersonalGrant(providers.value, models.value))
const snippet = computed<string | null>(() => {
if (!nameValid.value || !groupValid.value || !apiKeyValid.value || grantError.value) return null
const allGroups = extraGroups.value.length > 0 ? [primaryGroup.value, ...extraGroups.value] : null
return userJsonLine({
name: name.value.trim(),
apiKey: apiKey.value.trim(),
group: allGroups ? undefined : primaryGroup.value,
groups: allGroups ?? undefined,
providers: providers.value,
models: models.value,
admin: admin.value,
})
})
</script>
<template>
<div class="flex flex-col gap-4">
<div class="grid grid-cols-1 gap-3 sm:grid-cols-2">
<div class="flex flex-col gap-1">
<label for="user-name" class="text-xs font-medium text-muted-foreground">Name</label>
<Input id="user-name" v-model="name" placeholder="alice" />
</div>
<div class="flex flex-col gap-1">
<label id="user-group-label" class="text-xs font-medium text-muted-foreground">Primary group</label>
<Select :model-value="primaryGroup" @update:model-value="(v) => (primaryGroup = String(v))">
<SelectTrigger aria-labelledby="user-group-label"><SelectValue placeholder="Select…" /></SelectTrigger>
<SelectContent>
<SelectItem v-for="g in props.groupNames" :key="g" :value="g">{{ g }}</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<div class="flex flex-col gap-1">
<label for="user-api-key" class="text-xs font-medium text-muted-foreground">API key</label>
<div class="flex gap-2">
<Input id="user-api-key" v-model="apiKey" class="font-mono" />
<Button type="button" variant="outline" size="sm" @click="generate">Generate</Button>
</div>
<p v-if="apiKey === API_KEY_PLACEHOLDER" class="text-xs text-muted-foreground">Still the placeholder — click Generate for a real key.</p>
</div>
<div class="flex flex-col gap-1">
<label for="user-extra-groups" class="text-xs font-medium text-muted-foreground">Additional groups (comma-separated, optional)</label>
<Input id="user-extra-groups" v-model="extraGroupsRaw" placeholder="friends" />
</div>
<div class="grid grid-cols-1 gap-3 sm:grid-cols-2">
<div class="flex flex-col gap-1">
<label for="user-providers" class="text-xs font-medium text-muted-foreground">Personal grant: providers (optional)</label>
<Input id="user-providers" v-model="providersRaw" placeholder="gx10" />
</div>
<div class="flex flex-col gap-1">
<label for="user-models" class="text-xs font-medium text-muted-foreground">Personal grant: models (optional)</label>
<Input id="user-models" v-model="modelsRaw" placeholder="gx10/current" />
</div>
</div>
<label class="flex items-center gap-2 text-sm">
<input v-model="admin" type="checkbox" class="size-4 rounded border-input" />
Admin (full access to /admin/api/*)
</label>
<Alert v-if="grantError" variant="destructive">
<AlertDescription>{{ grantError }}</AlertDescription>
</Alert>
<SnippetBlock v-if="snippet" label="Add as a new line in users.json" :text="snippet" />
<p v-else class="text-sm text-muted-foreground">Fill in a name, group, and a real (generated) API key to build a snippet.</p>
</div>
</template>