mirror of
https://github.com/lukaszraczylo/traefik-llmgateway.git
synced 2026-10-16 05:37:28 +00:00
feat(webui): add limit bars, forecasts, events, exports and URL state
The Usage tab shows a bar for each configured limit. It turns amber at 80% and red at 100%. The tab also shows rejected requests per day, a projected month-end cost with budget headroom, and CSV export for users and groups. Names link to Charts with the matching scope. The Charts tab ranks models over the full 24h, 30d or 12mo window. A provider filter is applied on the server, and the ranking can be exported as CSV. A new Events tab lists recent failures, filtered by kind and user. The selected tab, filters and scope live in the URL hash. Polling stops while the page is hidden. Views show which replica answered, and a banner lists configuration warnings. CSV cells that start with a formula character are neutralised. Links sit outside the accordion buttons, so both controls keep their accessible names. admin_assets_gen.go is regenerated. Claude-Session: https://claude.ai/code/session_01V5jYJNxpDFdJkyyExpADrv
This commit is contained in:
+131
-12
@@ -2,7 +2,11 @@ import type { ColumnDef } from '@tanstack/vue-table'
|
||||
import { h } from 'vue'
|
||||
|
||||
import CompactNumber from '@/components/CompactNumber.vue'
|
||||
import { formatCost, formatLimits } from '@/lib/format'
|
||||
import ScopeLink from '@/components/ScopeLink.vue'
|
||||
import UsageBar from '@/components/UsageBar.vue'
|
||||
import { headroom, monthProgress, projectMonthEnd } from '@/lib/forecast'
|
||||
import { formatCost, formatExactInt, formatLimits } from '@/lib/format'
|
||||
import { BUDGET_RATIO_LABEL, budgetRatios } from '@/lib/usage-bars'
|
||||
import type { AdminUsageEntryView } from '@/types/api'
|
||||
|
||||
/**
|
||||
@@ -22,6 +26,35 @@ import type { AdminUsageEntryView } from '@/types/api'
|
||||
* this feature's concern. The accessorFn (and therefore sorting) always
|
||||
* reads the true raw number either way — only the cell's rendered text
|
||||
* changes.
|
||||
*
|
||||
* F1 (usage-vs-limit bars): `budgetId` optionally names WHICH
|
||||
* lib/usage-bars.ts BudgetRatio this column illustrates (one of
|
||||
* budgetRatios' own ids — "reqMin", "reqDay", "tokDay", "tokMonth",
|
||||
* "costDay", "costMonth"). When the row is not storeDown and
|
||||
* budgetRatios(row) actually contains that id (i.e. the scope has that
|
||||
* limit configured), the cell renders a UsageBar beside the number.
|
||||
*
|
||||
* P11 review fix (DRY + correctness): this used to take its own
|
||||
* `limitOf`/`usedOf` accessor pair and re-derive the USD->micro-USD
|
||||
* conversion locally (a dropped usdLimitMicros helper) — a second,
|
||||
* independent implementation of exactly what budgetRatios already computes,
|
||||
* and one that disagreed with it: budgetRatios gated on the RAW USD value
|
||||
* being truthy before rounding (a limit rounding to 0 micro-USD produced an
|
||||
* Infinity/NaN ratio there), while this column's own usdLimitMicros rounded
|
||||
* first — the two could show a bar in one place, mask it in the other, for
|
||||
* the identical scope. Reusing budgetRatios here (now fixed to round-then-
|
||||
* skip, matching Go — see that module's own doc comment) makes both paths
|
||||
* agree by construction: there is only one ratio computation left, not two.
|
||||
*
|
||||
* BUDGET_RATIO_LABEL[budgetId] (P10 review fix), not the column's own
|
||||
* `header`, backs the bar's accessible label: the token columns'
|
||||
* "tokDay"/"tokMonth" ratio combines BOTH directions
|
||||
* (budgetRatios' own doc comment), so the bar's aria-label spells out
|
||||
* "tokens (in+out)/day" — the column HEADER stays "tokIn/day"/"tokOut/day"
|
||||
* (that identity is real and worth keeping visually), but a screen-reader
|
||||
* user hearing just "tokIn/day, 1,500 / 2,000" beside a cell showing 500
|
||||
* has no other cue that the meter measures the combined total, not that
|
||||
* column's own count.
|
||||
*/
|
||||
function numericColumn(
|
||||
id: string,
|
||||
@@ -29,6 +62,7 @@ function numericColumn(
|
||||
read: (entry: AdminUsageEntryView) => number,
|
||||
format: (n: number) => string = String,
|
||||
compact = true,
|
||||
budgetId?: string,
|
||||
): ColumnDef<AdminUsageEntryView, unknown> {
|
||||
return {
|
||||
id,
|
||||
@@ -38,7 +72,80 @@ function numericColumn(
|
||||
cell: ({ row }) => {
|
||||
if (row.original.storeDown) return '?'
|
||||
const value = read(row.original)
|
||||
return compact ? h(CompactNumber, { value }) : format(value)
|
||||
const valueNode = compact ? h(CompactNumber, { value }) : format(value)
|
||||
if (!budgetId) return valueNode
|
||||
const ratio = budgetRatios(row.original).find((r) => r.id === budgetId)
|
||||
if (!ratio) return valueNode
|
||||
const valueText = compact
|
||||
? `${formatExactInt(ratio.used)} / ${formatExactInt(ratio.limit)}`
|
||||
: `${format(ratio.used)} / ${format(ratio.limit)}`
|
||||
return h('span', { class: 'inline-flex items-center justify-end gap-1.5' }, [
|
||||
valueNode,
|
||||
h(UsageBar, { ratio: ratio.ratio, label: BUDGET_RATIO_LABEL[budgetId] ?? header, valueText }),
|
||||
])
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* rejDayColumn (F4) renders rejectionsPerDay — fleet-wide requests denied
|
||||
* by a limit check today (types/api.ts's own doc comment). Not built via
|
||||
* numericColumn: it needs a destructive tint whenever the count is
|
||||
* positive at all (coordinator brief: "destructive tint when > 0"),
|
||||
* independent of any configured limit/bar — a rejection count itself IS
|
||||
* the signal, there is nothing to compare it against.
|
||||
*/
|
||||
function rejDayColumn(): ColumnDef<AdminUsageEntryView, unknown> {
|
||||
return {
|
||||
id: 'rejDay',
|
||||
header: 'rejected/day',
|
||||
accessorFn: (entry) => entry.rejectionsPerDay,
|
||||
meta: { align: 'right' },
|
||||
cell: ({ row }) => {
|
||||
if (row.original.storeDown) return '?'
|
||||
const value = row.original.rejectionsPerDay
|
||||
return h(CompactNumber, { value, class: value > 0 ? 'text-destructive' : undefined })
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* costMonthProjColumn (F7) renders lib/forecast.ts's projected month-end
|
||||
* cost for each row, extrapolated from that row's own costPerMonthMicroUsd
|
||||
* — the same math CostForecastCard.vue applies to the Total entry, here
|
||||
* applied per user/group so an operator can see WHICH scope is on track
|
||||
* to blow its own cost/month limit, not just the fleet-wide total.
|
||||
*
|
||||
* `now` is threaded in from the caller (coordinator brief: "now passed as
|
||||
* prop for determinism") rather than read via `new Date()` inside this
|
||||
* module — usageColumns() takes it as an optional parameter, defaulting
|
||||
* to the real current time for every production call site, so a test can
|
||||
* pin it instead. elapsedFraction is computed ONCE per usageColumns() call
|
||||
* (not per row) since every row projects against the identical point in
|
||||
* the current UTC month.
|
||||
*/
|
||||
function costMonthProjColumn(elapsedFraction: number): ColumnDef<AdminUsageEntryView, unknown> {
|
||||
return {
|
||||
id: 'costMonthProj',
|
||||
header: 'cost/month (proj.)',
|
||||
meta: { align: 'right' },
|
||||
// Sorting: a not-enough-data-yet row (null projection) has no real
|
||||
// figure to compare — it sorts as if projected to 0, the smallest
|
||||
// possible spend, rather than being excluded from sorting entirely.
|
||||
accessorFn: (entry) => projectMonthEnd(entry.costPerMonthMicroUsd, elapsedFraction) ?? 0,
|
||||
cell: ({ row }) => {
|
||||
if (row.original.storeDown) return '?'
|
||||
const projected = projectMonthEnd(row.original.costPerMonthMicroUsd, elapsedFraction)
|
||||
if (projected === null) return h('span', { class: 'text-muted-foreground' }, 'not enough data yet')
|
||||
const result = headroom(projected, row.original.limits?.costPerMonthUSD)
|
||||
return h(
|
||||
'span',
|
||||
{
|
||||
class: result.willExceed ? 'text-destructive' : undefined,
|
||||
title: result.willExceed ? 'Projected to exceed the configured cost/month limit' : undefined,
|
||||
},
|
||||
formatCost(projected),
|
||||
)
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -52,19 +159,29 @@ function numericColumn(
|
||||
* bar, so both surfaces sort by the exact same column semantics.
|
||||
* idLabel/secondaryColumnLabel name the first two columns; secondaryValue
|
||||
* reads the second column's per-row value (a user's group name, or a
|
||||
* group's member count).
|
||||
* group's member count). `now` (F7) defaults to the real current time —
|
||||
* see costMonthProjColumn's own doc comment for why a caller can override
|
||||
* it.
|
||||
*/
|
||||
export function usageColumns(
|
||||
idLabel: string,
|
||||
secondaryColumnLabel: string,
|
||||
secondaryValue: (entry: AdminUsageEntryView) => string,
|
||||
now: Date = new Date(),
|
||||
): ColumnDef<AdminUsageEntryView, unknown>[] {
|
||||
const elapsedFraction = monthProgress(now)
|
||||
return [
|
||||
{
|
||||
id: 'id',
|
||||
header: idLabel,
|
||||
accessorFn: (entry) => entry.id,
|
||||
cell: ({ row }) => h('span', { class: 'font-medium' }, row.original.id),
|
||||
// F6: ScopeLink jumps to this row's own Charts scope — "user:{id}"
|
||||
// or "group:{id}", matching entry.kind exactly (admin.go only ever
|
||||
// sets kind to "user"/"group"/"total"; a table row is always the
|
||||
// first two, the "total" entry renders separately in the Total
|
||||
// card, never as a table row — see UsageView.vue).
|
||||
cell: ({ row }) =>
|
||||
h(ScopeLink, { class: 'font-medium', label: row.original.id, scope: `${row.original.kind}:${row.original.id}` }),
|
||||
},
|
||||
{
|
||||
id: 'secondary',
|
||||
@@ -90,13 +207,15 @@ export function usageColumns(
|
||||
accessorFn: (entry) => formatLimits(entry.limits),
|
||||
cell: ({ row }) => h('span', { class: 'text-muted-foreground' }, formatLimits(row.original.limits)),
|
||||
},
|
||||
numericColumn('reqMin', 'req/min', (e) => e.requestsPerMinute),
|
||||
numericColumn('reqDay', 'req/day', (e) => e.requestsPerDay),
|
||||
numericColumn('tokInDay', 'tokIn/day', (e) => e.tokensInPerDay),
|
||||
numericColumn('tokOutDay', 'tokOut/day', (e) => e.tokensOutPerDay),
|
||||
numericColumn('tokInMonth', 'tokIn/month', (e) => e.tokensInPerMonth),
|
||||
numericColumn('tokOutMonth', 'tokOut/month', (e) => e.tokensOutPerMonth),
|
||||
numericColumn('costDay', 'cost/day', (e) => e.costPerDayMicroUsd, formatCost, false),
|
||||
numericColumn('costMonth', 'cost/month', (e) => e.costPerMonthMicroUsd, formatCost, false),
|
||||
numericColumn('reqMin', 'req/min', (e) => e.requestsPerMinute, String, true, 'reqMin'),
|
||||
numericColumn('reqDay', 'req/day', (e) => e.requestsPerDay, String, true, 'reqDay'),
|
||||
rejDayColumn(),
|
||||
numericColumn('tokInDay', 'tokIn/day', (e) => e.tokensInPerDay, String, true, 'tokDay'),
|
||||
numericColumn('tokOutDay', 'tokOut/day', (e) => e.tokensOutPerDay, String, true, 'tokDay'),
|
||||
numericColumn('tokInMonth', 'tokIn/month', (e) => e.tokensInPerMonth, String, true, 'tokMonth'),
|
||||
numericColumn('tokOutMonth', 'tokOut/month', (e) => e.tokensOutPerMonth, String, true, 'tokMonth'),
|
||||
numericColumn('costDay', 'cost/day', (e) => e.costPerDayMicroUsd, formatCost, false, 'costDay'),
|
||||
numericColumn('costMonth', 'cost/month', (e) => e.costPerMonthMicroUsd, formatCost, false, 'costMonth'),
|
||||
costMonthProjColumn(elapsedFraction),
|
||||
]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user