mirror of
https://github.com/lukaszraczylo/traefik-llmgateway.git
synced 2026-10-13 04:58:50 +00:00
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
102 lines
3.9 KiB
TypeScript
102 lines
3.9 KiB
TypeScript
import type { ColumnDef } from '@tanstack/vue-table'
|
|
import { h } from 'vue'
|
|
|
|
import { Badge } from '@/components/ui/badge'
|
|
import { EVENT_KIND_LABEL, eventKindVariant } from '@/lib/events-filter'
|
|
import { formatTimestamp } from '@/lib/format'
|
|
import type { AdminEventKind, AdminEventView } from '@/types/api'
|
|
|
|
/** kindLabel falls back to the raw kind string for a version-skewed server build emitting a kind EVENT_KIND_LABEL does not know — same "still render it" convention AdminEventView.kind's own `| string` union documents. */
|
|
function kindLabel(kind: string): string {
|
|
return EVENT_KIND_LABEL[kind as AdminEventKind] ?? kind
|
|
}
|
|
|
|
/** scopeText renders one event's user/group — a user-scoped event never carries a group and vice versa (AdminEventView's own doc comment), so at most one of the two is ever present; an event with neither (a total/all-scope event, e.g. a capacity rejection recorded user-less — DECISIONS Q4) renders an em dash. */
|
|
function scopeText(e: AdminEventView): string {
|
|
if (e.user) return `user:${e.user}`
|
|
if (e.group) return `group:${e.group}`
|
|
return '—'
|
|
}
|
|
|
|
/** targetText renders one event's model/provider pair — joined when both are known (an upstream/timeout event names both), just the one that is known otherwise, and an em dash when neither applies (a rate_limit/budget event never carries either). */
|
|
function targetText(e: AdminEventView): string {
|
|
const parts = [e.model, e.provider].filter((v): v is string => Boolean(v))
|
|
return parts.length ? parts.join(' → ') : '—'
|
|
}
|
|
|
|
/**
|
|
* eventsColumns builds the Events tab's DataTable ColumnDef set
|
|
* (admin.go/events.go: gatewayEvent — F3). Mirrors target-columns.ts's own
|
|
* shape: a Badge for the classified `kind` column (eventKindVariant), plain
|
|
* muted text for everything free-form (route/message), an em dash for a
|
|
* field this row does not carry rather than a blank cell.
|
|
*/
|
|
export function eventsColumns(): ColumnDef<AdminEventView, unknown>[] {
|
|
return [
|
|
{
|
|
id: 'time',
|
|
header: 'Time',
|
|
accessorFn: (e) => e.time,
|
|
cell: ({ row }) => h('span', { class: 'text-muted-foreground tabular-nums' }, formatTimestamp(row.original.time)),
|
|
},
|
|
{
|
|
id: 'kind',
|
|
header: 'Kind',
|
|
accessorFn: (e) => e.kind,
|
|
cell: ({ row }) =>
|
|
h(
|
|
Badge,
|
|
{ as: 'span', variant: eventKindVariant(row.original.kind), class: 'font-normal' },
|
|
() => kindLabel(row.original.kind),
|
|
),
|
|
},
|
|
{
|
|
id: 'route',
|
|
header: 'Route',
|
|
accessorFn: (e) => e.route,
|
|
cell: ({ row }) => h('span', { class: 'font-mono text-xs' }, row.original.route),
|
|
},
|
|
{
|
|
id: 'scope',
|
|
header: 'User / group',
|
|
enableSorting: false,
|
|
accessorFn: (e) => e.user ?? e.group ?? '',
|
|
cell: ({ row }) => {
|
|
const text = scopeText(row.original)
|
|
return h('span', { class: text === '—' ? 'text-muted-foreground' : undefined }, text)
|
|
},
|
|
},
|
|
{
|
|
id: 'target',
|
|
header: 'Model / provider',
|
|
enableSorting: false,
|
|
accessorFn: (e) => `${e.model ?? ''} ${e.provider ?? ''}`,
|
|
cell: ({ row }) => {
|
|
const text = targetText(row.original)
|
|
return h('span', { class: text === '—' ? 'text-muted-foreground' : 'font-mono text-xs' }, text)
|
|
},
|
|
},
|
|
{
|
|
id: 'replica',
|
|
header: 'Replica',
|
|
accessorFn: (e) => e.replica,
|
|
cell: ({ row }) => h('span', { class: 'text-muted-foreground' }, row.original.replica),
|
|
},
|
|
{
|
|
id: 'status',
|
|
header: 'Status',
|
|
meta: { align: 'right' },
|
|
accessorFn: (e) => e.status ?? 0,
|
|
cell: ({ row }) =>
|
|
h('span', { class: 'tabular-nums' }, row.original.status !== undefined ? String(row.original.status) : '—'),
|
|
},
|
|
{
|
|
id: 'message',
|
|
header: 'Message',
|
|
enableSorting: false,
|
|
accessorFn: (e) => e.message,
|
|
cell: ({ row }) => h('span', { class: 'text-muted-foreground' }, row.original.message),
|
|
},
|
|
]
|
|
}
|