diff --git a/webui/src/App.vue b/webui/src/App.vue index d70fc6f..3153723 100644 --- a/webui/src/App.vue +++ b/webui/src/App.vue @@ -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(() => { if (dashboard.error) return `refresh failed: ${dashboard.error}` @@ -72,6 +73,7 @@ onMounted(() => { Providers Usage Charts + MCP & Agents @@ -82,6 +84,9 @@ onMounted(() => { + + + diff --git a/webui/src/components/TargetsView.vue b/webui/src/components/TargetsView.vue new file mode 100644 index 0000000..154d12c --- /dev/null +++ b/webui/src/components/TargetsView.vue @@ -0,0 +1,77 @@ + + + diff --git a/webui/src/lib/target-columns.ts b/webui/src/lib/target-columns.ts new file mode 100644 index 0000000..434e379 --- /dev/null +++ b/webui/src/lib/target-columns.ts @@ -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 { + 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[] { + 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), + ] +} diff --git a/webui/src/stores/dashboard.ts b/webui/src/stores/dashboard.ts index 35d33f5..bb87606 100644 --- a/webui/src/stores/dashboard.ts +++ b/webui/src/stores/dashboard.ts @@ -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 | 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('/admin/api/overview'), adminFetch('/admin/api/usage'), + adminFetch('/admin/api/targets'), ]) this.overview = overview this.usage = usage + this.targets = targets this.lastUpdated = new Date() this.error = '' } catch (err) { diff --git a/webui/src/types/api.ts b/webui/src/types/api.ts index d8a394d..bf43464 100644 --- a/webui/src/types/api.ts +++ b/webui/src/types/api.ts @@ -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[] +}