Files
traefik-llmgateway/webui/src/components/PricingHealthTable.vue
T
lukaszraczylo ee49467e64 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
2026-09-23 16:41:01 +01:00

153 lines
5.6 KiB
Vue

<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>