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
+49 -64
View File
@@ -1,64 +1,66 @@
<script setup lang="ts">
import type { Component } from 'vue'
import { faGithub } from '@fortawesome/free-brands-svg-icons'
import { faCircleExclamation } from '@fortawesome/free-solid-svg-icons'
import { computed, defineAsyncComponent, onMounted } from 'vue'
import AuthGate from '@/components/AuthGate.vue'
import ConfigWarningsBanner from '@/components/ConfigWarningsBanner.vue'
import EventsView from '@/components/EventsView.vue'
import ProvidersView from '@/components/ProvidersView.vue'
import TargetsView from '@/components/TargetsView.vue'
import GlobalFilterBar from '@/components/GlobalFilterBar.vue'
import SidebarNav from '@/components/SidebarNav.vue'
import { Button } from '@/components/ui/button'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import UsageView from '@/components/UsageView.vue'
import { useHashState } from '@/composables/useHashState'
import type { PageId } from '@/lib/pages'
import { useAuthStore } from '@/stores/auth'
import { useDashboardStore } from '@/stores/dashboard'
import { useNavStore } from '@/stores/nav'
// Lazy: Chart.js (UsageChart.vue's dependency, pulled in transitively) is
// the single largest piece of this bundle, and most admin sessions open on
// Providers/Usage. Splitting it into its own chunk keeps the initial load
// lean; it fetches once, on first visit to the Charts tab.
const ChartsView = defineAsyncComponent(() => import('@/components/ChartsView.vue'))
/** The module path (go.mod / .traefik.yml `import:`) — same repo the plugin
* ships from, so this is the one canonical URL rather than a guess. */
/** The module path (go.mod / .traefik.yml `import:`) — same repo the plugin ships from, so this is the one canonical URL rather than a guess. */
const GITHUB_REPO_URL = 'https://github.com/lukaszraczylo/traefik-llmgateway'
const auth = useAuthStore()
const dashboard = useDashboardStore()
const nav = useNavStore()
// The "Providers" tab is labeled that way (operator rename); the backend
// endpoint it fetches from, GET /admin/api/overview, and the store's own
// `overview` field (AdminOverviewResponse) keep their original names
// unchanged — the rename is UI-only, not an API surface change.
// F9 (dashboard-plan.md) supersedes Feature C (v0.22)'s useTabHash()
// composable: the Tabs v-model now binds nav.activeTab (stores/nav.ts)
// directly, and useHashState() keeps that field — plus history.ts's
// scope/window/tab/modelMetric, events.ts's kindFilter/userFilter, and
// nav.usageQuery — synced with a single "#<tab>?<query>" URL hash
// (lib/hash-state.ts), both on load and on every change. MUST be created
// here, before the `v-if="!auth.isAuthenticated"` branch below (Risks,
// dashboard-plan.md: "load-with-hash-then-authenticate") — every store
// action it calls is safe to run before a key is stored, so the hash's
// selection is already restored by the time the reader authenticates.
// The old flat-tab shell (activeTab/useTabHash) is superseded by the
// sidebar-page redesign (redesign-plan.md section 3.1): nav.page/params
// now drive routing, and useHashState() keeps them — plus the global
// filters (stores/filters.ts) — synced with a single
// "#<page>?range=...&cmp=...&scope=...&<page params>" URL hash. MUST be
// created here, before the `v-if="!auth.isAuthenticated"` branch below
// (load-with-hash-then-authenticate) — every store write it makes is
// safe to run before a key is stored, so the hash's selection is already
// restored by the time the reader authenticates.
useHashState()
/**
* PAGES maps each PageId (lib/pages.ts) to its own top-level page
* component, lazily loaded — an admin session only ever looks at one
* page at a time, so only that page's own code (and, for the pages
* other work packages are filling in with chart.js-backed views) ships
* on first load, generalizing the same reasoning the old shell already
* applied to its one chart-heavy tab.
*/
const PAGES: Record<PageId, Component> = {
home: defineAsyncComponent(() => import('@/pages/HomePage.vue')),
spend: defineAsyncComponent(() => import('@/pages/SpendPage.vue')),
consumers: defineAsyncComponent(() => import('@/pages/ConsumersPage.vue')),
models: defineAsyncComponent(() => import('@/pages/ModelsPage.vue')),
reliability: defineAsyncComponent(() => import('@/pages/ReliabilityPage.vue')),
config: defineAsyncComponent(() => import('@/pages/ConfigPage.vue')),
targets: defineAsyncComponent(() => import('@/pages/TargetsPage.vue')),
}
const currentPage = computed<Component>(() => PAGES[nav.page])
// statusText reads dashboard.error/lastUpdated unconditionally — it is
// correct only while the template's own `v-if="auth.isAuthenticated"` below
// keeps it off-screen otherwise. Without that gate a 401 (which resolves
// before touching error/lastUpdated) left the PRIOR "last updated ..." or
// "refresh failed: ..." line showing right under the auth gate, and a first
// load with no stored key showed "loading..." forever (review finding).
// F4 (dashboard-plan.md): appended only on a genuinely successful poll —
// correct only while the template's own `v-if="auth.isAuthenticated"`
// below keeps it off-screen otherwise, so a first load with no stored
// key never shows a stale "refresh failed"/"loading..." line under the
// auth gate. Appended only on a genuinely successful poll —
// dashboard.overview.replica reflects WHICH replica answered that poll,
// so it must never be shown alongside a stale value during a failed or
// still-loading refresh (a prior successful poll's overview object can
// still be sitting there while `error` is set, and showing its replica
// next to "refresh failed" would misattribute the failure to it).
// still-loading refresh.
const statusText = computed<string>(() => {
if (dashboard.error) return `refresh failed: ${dashboard.error}`
if (dashboard.lastUpdated) {
@@ -74,8 +76,8 @@ onMounted(() => {
</script>
<template>
<div class="flex w-full flex-col gap-6 px-4 py-8 sm:px-6 lg:px-8">
<header class="flex flex-col gap-1">
<div class="flex w-full flex-col gap-4 px-4 py-6 sm:px-6 lg:px-8">
<header class="flex flex-col gap-3">
<div class="flex items-center justify-between gap-2">
<h1 class="text-xl font-semibold tracking-tight">LLM Gateway &mdash; Admin</h1>
<Button
@@ -95,9 +97,10 @@ onMounted(() => {
<FontAwesomeIcon v-if="dashboard.error" :icon="faCircleExclamation" class="size-3.5 text-destructive" aria-hidden="true" />
<span :class="dashboard.error ? 'text-destructive' : undefined">{{ statusText }}</span>
</p>
<GlobalFilterBar v-if="auth.isAuthenticated" />
</header>
<!-- F10: GET /admin/api/overview's config warnings — renders nothing itself once `warnings` is empty, so this is always safe to mount. -->
<!-- GET /admin/api/overview's config warnings — renders nothing itself once `warnings` is empty, so this is always safe to mount. -->
<ConfigWarningsBanner
v-if="auth.isAuthenticated"
:warnings="dashboard.overview?.warnings ?? []"
@@ -106,29 +109,11 @@ onMounted(() => {
<AuthGate v-if="!auth.isAuthenticated" />
<Tabs v-else v-model="nav.activeTab">
<TabsList>
<TabsTrigger value="providers">Providers</TabsTrigger>
<TabsTrigger value="usage">Usage</TabsTrigger>
<TabsTrigger value="charts">Charts</TabsTrigger>
<TabsTrigger value="targets">MCP & Agents</TabsTrigger>
<TabsTrigger value="events">Events</TabsTrigger>
</TabsList>
<TabsContent value="providers">
<ProvidersView />
</TabsContent>
<TabsContent value="usage">
<UsageView />
</TabsContent>
<TabsContent value="charts">
<ChartsView />
</TabsContent>
<TabsContent value="targets">
<TargetsView />
</TabsContent>
<TabsContent value="events">
<EventsView />
</TabsContent>
</Tabs>
<div v-else class="flex flex-col gap-4 md:flex-row">
<SidebarNav />
<main class="min-w-0 flex-1">
<component :is="currentPage" />
</main>
</div>
</div>
</template>