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:
2026-08-21 18:27:04 +01:00
parent 66fa0b56ea
commit 7ad573b63e
5 changed files with 199 additions and 10 deletions
+6 -1
View File
@@ -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>
+77
View File
@@ -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>
+74
View File
@@ -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),
]
}
+14 -9
View File
@@ -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) {
+28
View File
@@ -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[]
}