mirror of
https://github.com/lukaszraczylo/traefik-llmgateway.git
synced 2026-10-16 05:37:28 +00:00
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:
+49
-64
@@ -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 — 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>
|
||||
|
||||
Reference in New Issue
Block a user