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
+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),
]
}