mirror of
https://github.com/lukaszraczylo/traefik-llmgateway.git
synced 2026-10-12 04:54:45 +00:00
feat(webui): MCP & Agents admin tab
New 'MCP & Agents' tab (after Charts) fed by GET /admin/api/targets: two Card panels, each a sortable DataTable (reusing DataTable/ SortHeaderButton — no hand-rolled table markup) listing configured MCP servers and agents with name, url (muted, truncated with a title tooltip), access (Badge per allowed group, muted 'All groups' when unrestricted — the same idiom UsageView's own group Access block already uses), and req/min, req/day, req/month. - types/api.ts: AdminTargetCountersView/AdminTargetView/ AdminTargetsResponse matching admin.go's JSON exactly. - stores/dashboard.ts: targets fetched in the SAME Promise.all/5s poll as overview and usage — one store, one timer, not a third. - lib/target-columns.ts: shared ColumnDef set both DataTables use. - components/TargetsView.vue: SearchInput + useSearchQuery name filter (inline matching, mirroring ProvidersView's own single-field aliasMatches — not every filter needs a lib/*-search.ts module).
This commit is contained in:
+6
-1
@@ -5,6 +5,7 @@ import { computed, defineAsyncComponent, onMounted, ref } from 'vue'
|
||||
|
||||
import AuthGate from '@/components/AuthGate.vue'
|
||||
import ProvidersView from '@/components/ProvidersView.vue'
|
||||
import TargetsView from '@/components/TargetsView.vue'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
||||
import UsageView from '@/components/UsageView.vue'
|
||||
@@ -28,7 +29,7 @@ const dashboard = useDashboardStore()
|
||||
// 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.
|
||||
const activeView = ref<'providers' | 'usage' | 'charts'>('providers')
|
||||
const activeView = ref<'providers' | 'usage' | 'charts' | 'targets'>('providers')
|
||||
|
||||
const statusText = computed<string>(() => {
|
||||
if (dashboard.error) return `refresh failed: ${dashboard.error}`
|
||||
@@ -72,6 +73,7 @@ onMounted(() => {
|
||||
<TabsTrigger value="providers">Providers</TabsTrigger>
|
||||
<TabsTrigger value="usage">Usage</TabsTrigger>
|
||||
<TabsTrigger value="charts">Charts</TabsTrigger>
|
||||
<TabsTrigger value="targets">MCP & Agents</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent value="providers">
|
||||
<ProvidersView />
|
||||
@@ -82,6 +84,9 @@ onMounted(() => {
|
||||
<TabsContent value="charts">
|
||||
<ChartsView />
|
||||
</TabsContent>
|
||||
<TabsContent value="targets">
|
||||
<TargetsView />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
|
||||
import DataTable from '@/components/DataTable.vue'
|
||||
import SearchInput from '@/components/SearchInput.vue'
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { useSearchQuery } from '@/composables/useSearchQuery'
|
||||
import { targetColumns } from '@/lib/target-columns'
|
||||
import { useDashboardStore } from '@/stores/dashboard'
|
||||
import type { AdminTargetView } from '@/types/api'
|
||||
|
||||
// This component backs the "MCP & Agents" tab (App.vue) — GET
|
||||
// /admin/api/targets (admin.go: adminTargetsResponse, Feature B v0.21).
|
||||
const dashboard = useDashboardStore()
|
||||
const targets = computed(() => dashboard.targets)
|
||||
|
||||
// Simple, single-field substring match on name — no separate lib/*-search
|
||||
// module: ProvidersView.vue's own aliasMatches/providerMatches stay inline
|
||||
// for the identical reason (one field, one comparison), reserving the
|
||||
// extracted-and-tested lib/usage-search.ts treatment for UsageView.vue's
|
||||
// genuinely multi-entity (user-or-group-member) matching semantics.
|
||||
const { query, normalized: normalizedQuery, hasQuery } = useSearchQuery()
|
||||
|
||||
function nameMatches(t: AdminTargetView): boolean {
|
||||
return t.name.toLowerCase().includes(normalizedQuery.value)
|
||||
}
|
||||
|
||||
const filteredMCPServers = computed<AdminTargetView[]>(() => {
|
||||
const all = targets.value?.mcpServers ?? []
|
||||
return hasQuery.value ? all.filter(nameMatches) : all
|
||||
})
|
||||
const filteredAgents = computed<AdminTargetView[]>(() => {
|
||||
const all = targets.value?.agents ?? []
|
||||
return hasQuery.value ? all.filter(nameMatches) : all
|
||||
})
|
||||
|
||||
/** emptyMessage mirrors UsageView.vue's groupsEmptyMessage/usersEmptyMessage three-way pattern: distinguishes nothing configured from nothing matching the active query. */
|
||||
function emptyMessage(configuredCount: number, filteredCount: number): string {
|
||||
if (!configuredCount) return 'none configured'
|
||||
if (hasQuery.value && filteredCount === 0) return `no targets match "${query.value}"`
|
||||
return 'none'
|
||||
}
|
||||
|
||||
const mcpEmptyMessage = computed(() => emptyMessage(targets.value?.mcpServers.length ?? 0, filteredMCPServers.value.length))
|
||||
const agentsEmptyMessage = computed(() => emptyMessage(targets.value?.agents.length ?? 0, filteredAgents.value.length))
|
||||
|
||||
// One shared column-def set (lib/target-columns.ts) for both tables below —
|
||||
// stateless ColumnDef objects, safe to reuse across DataTable's two
|
||||
// separate useVueTable instances (each owns its own sorting state).
|
||||
const columns = targetColumns()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col gap-6">
|
||||
<SearchInput v-model="query" placeholder="Filter MCP servers or agents" class="max-w-sm" />
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>MCP servers</CardTitle>
|
||||
<CardDescription>Configured MCP proxy targets, the groups allowed to reach them, and their request counters.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<DataTable :columns="columns" :data="filteredMCPServers" :empty-message="mcpEmptyMessage" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Agents</CardTitle>
|
||||
<CardDescription>Configured A2A agent proxy targets, the groups allowed to reach them, and their request counters.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<DataTable :columns="columns" :data="filteredAgents" :empty-message="agentsEmptyMessage" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,74 @@
|
||||
import type { ColumnDef } from '@tanstack/vue-table'
|
||||
import { h } from 'vue'
|
||||
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import type { AdminTargetView } from '@/types/api'
|
||||
|
||||
/**
|
||||
* numericColumn builds one right-aligned, sortable request-counter column.
|
||||
* Mirrors usage-columns.ts's own helper of the same name and purpose, but
|
||||
* without that one's storeDown '?' masking: a target's counters carry no
|
||||
* storeDown flag (limiter.targetUsage, limits.go — a target scope has no
|
||||
* limit to protect from a misleadingly-confident zero, unlike a limited
|
||||
* user/group scope), so there is nothing to mask here.
|
||||
*/
|
||||
function numericColumn(id: string, header: string, read: (t: AdminTargetView) => number): ColumnDef<AdminTargetView, unknown> {
|
||||
return {
|
||||
id,
|
||||
header,
|
||||
accessorFn: read,
|
||||
meta: { align: 'right' },
|
||||
cell: ({ row }) => String(read(row.original)),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* targetColumns builds the shared TanStack `ColumnDef` set both the MCP
|
||||
* servers and the Agents DataTable in TargetsView.vue use (admin.go:
|
||||
* adminTargetView — the two tables share one identical row shape). name is
|
||||
* plain text (not the ModelChip copy treatment — a target name is not a
|
||||
* routable model id); url is muted and truncated with a title attribute
|
||||
* for the full value; access renders as a Badge per allowed group, or a
|
||||
* muted "All groups" when unrestricted — the exact idiom UsageView.vue's
|
||||
* own group Access block already established (Providers/Models/MCP
|
||||
* servers/Agents dt/dd pairs there).
|
||||
*/
|
||||
export function targetColumns(): ColumnDef<AdminTargetView, unknown>[] {
|
||||
return [
|
||||
{
|
||||
id: 'name',
|
||||
header: 'Name',
|
||||
accessorFn: (t) => t.name,
|
||||
cell: ({ row }) => h('span', { class: 'font-medium' }, row.original.name),
|
||||
},
|
||||
{
|
||||
id: 'url',
|
||||
header: 'URL',
|
||||
accessorFn: (t) => t.url,
|
||||
cell: ({ row }) =>
|
||||
h(
|
||||
'span',
|
||||
{ class: 'block max-w-xs truncate text-muted-foreground', title: row.original.url },
|
||||
row.original.url,
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'access',
|
||||
header: 'Access',
|
||||
enableSorting: false,
|
||||
accessorFn: (t) => (t.access ?? []).join(', '),
|
||||
cell: ({ row }) => {
|
||||
const access = row.original.access
|
||||
if (!access?.length) return h('span', { class: 'text-muted-foreground' }, 'All groups')
|
||||
return h(
|
||||
'div',
|
||||
{ class: 'flex flex-wrap gap-1.5' },
|
||||
access.map((name) => h(Badge, { key: name, as: 'span', variant: 'secondary', class: 'font-mono font-normal' }, () => name)),
|
||||
)
|
||||
},
|
||||
},
|
||||
numericColumn('reqMin', 'req/min', (t) => t.counters.requestsPerMinute),
|
||||
numericColumn('reqDay', 'req/day', (t) => t.counters.requestsPerDay),
|
||||
numericColumn('reqMonth', 'req/month', (t) => t.counters.requestsPerMonth),
|
||||
]
|
||||
}
|
||||
@@ -2,23 +2,26 @@ import { defineStore } from 'pinia'
|
||||
|
||||
import { AdminApiError, adminFetch } from '@/lib/api'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import type { AdminOverviewResponse, AdminUsageResponse } from '@/types/api'
|
||||
import type { AdminOverviewResponse, AdminTargetsResponse, AdminUsageResponse } from '@/types/api'
|
||||
|
||||
/** How often the Providers/Usage views poll (matches the replaced vanilla-JS page). */
|
||||
/** How often the Providers/Usage/Targets views poll (matches the replaced vanilla-JS page). */
|
||||
const POLL_MS = 5000
|
||||
|
||||
/**
|
||||
* useDashboardStore polls GET /admin/api/overview and GET /admin/api/usage
|
||||
* together every POLL_MS, feeding the Providers and Usage views (the store's
|
||||
* own `overview` field keeps the backend's name — see ProvidersView.vue's
|
||||
* doc comment for the UI-label/API-name split). A single
|
||||
* store (not two) because both routes are always fetched together — the
|
||||
* old vanilla-JS page's own Promise.all([overview, usage]) pattern.
|
||||
* useDashboardStore polls GET /admin/api/overview, GET /admin/api/usage,
|
||||
* and GET /admin/api/targets together every POLL_MS, feeding the
|
||||
* Providers, Usage, and MCP & Agents views (the store's own `overview`
|
||||
* field keeps the backend's name — see ProvidersView.vue's doc comment
|
||||
* for the UI-label/API-name split). A single store (not three) because
|
||||
* every route is always fetched together — the old vanilla-JS page's own
|
||||
* Promise.all([overview, usage]) pattern, extended to targets (Feature B,
|
||||
* v0.21) rather than given its own separate poll timer.
|
||||
*/
|
||||
export const useDashboardStore = defineStore('dashboard', {
|
||||
state: () => ({
|
||||
overview: null as AdminOverviewResponse | null,
|
||||
usage: null as AdminUsageResponse | null,
|
||||
targets: null as AdminTargetsResponse | null,
|
||||
lastUpdated: null as Date | null,
|
||||
error: '',
|
||||
timer: undefined as ReturnType<typeof setInterval> | undefined,
|
||||
@@ -28,12 +31,14 @@ export const useDashboardStore = defineStore('dashboard', {
|
||||
const auth = useAuthStore()
|
||||
if (!auth.isAuthenticated) return
|
||||
try {
|
||||
const [overview, usage] = await Promise.all([
|
||||
const [overview, usage, targets] = await Promise.all([
|
||||
adminFetch<AdminOverviewResponse>('/admin/api/overview'),
|
||||
adminFetch<AdminUsageResponse>('/admin/api/usage'),
|
||||
adminFetch<AdminTargetsResponse>('/admin/api/targets'),
|
||||
])
|
||||
this.overview = overview
|
||||
this.usage = usage
|
||||
this.targets = targets
|
||||
this.lastUpdated = new Date()
|
||||
this.error = ''
|
||||
} catch (err) {
|
||||
|
||||
@@ -116,3 +116,31 @@ export interface UsageHistoryResponse {
|
||||
window: string
|
||||
points: UsageHistoryPoint[]
|
||||
}
|
||||
|
||||
/** One MCP-server or agent target's current-window request counters (admin.go: adminTargetCountersView) — requests only, no tokens or cost. */
|
||||
export interface AdminTargetCountersView {
|
||||
requestsPerMinute: number
|
||||
requestsPerDay: number
|
||||
requestsPerMonth: number
|
||||
}
|
||||
|
||||
/**
|
||||
* One configured MCP server's or A2A agent's row in GET /admin/api/targets
|
||||
* (admin.go: adminTargetView). Access is the group names actually allowed
|
||||
* to reach this target, computed server-side via the same matching authz
|
||||
* enforcement uses — undefined/empty means every configured group can
|
||||
* reach it ("empty meaning all", mirroring AdminUsageEntryView's own
|
||||
* providers/models/mcpServers/agents convention above).
|
||||
*/
|
||||
export interface AdminTargetView {
|
||||
name: string
|
||||
url: string
|
||||
access?: string[]
|
||||
counters: AdminTargetCountersView
|
||||
}
|
||||
|
||||
/** GET /admin/api/targets (admin.go: adminTargetsResponse). */
|
||||
export interface AdminTargetsResponse {
|
||||
mcpServers: AdminTargetView[]
|
||||
agents: AdminTargetView[]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user