mirror of
https://github.com/lukaszraczylo/traefik-llmgateway.git
synced 2026-10-19 05:47:48 +00:00
feat(admin): expose group provider/model access
GET /admin/api/usage's group rows now carry the group's configured Providers/Models/MCPServers/Agents (GroupConfig, llmgateway.go), echoed exactly as configured -- never resolved/expanded to the full catalog server-side. Empty/unset stays omitted (omitempty), matching GroupConfig's own JSON convention and group.allowsX's empty-means-all semantics (auth.go): the dashboard can tell 'restricted to these N' apart from 'unrestricted' without the payload growing with catalog size. groupSummary (auth.go) gains providers/models/mcpServers/agents, carried through from authStore.snapshot unchanged. adminUsageEntryView (admin.go) gains the matching JSON fields, populated only for group entries -- a user or the total scope row never carries them, mirroring GroupName's own user-only convention. The Usage view's group accordion (webui/UsageView.vue) renders an Access block above the existing stat grid: Badge chips for explicit providers, ModelChip for explicit models (both shadcn/existing components, no hand-rolled chips), or muted 'All providers'/'All models' text when unrestricted. Display-only this round -- not wired into the user/group search filter. types/api.ts's AdminUsageEntryView mirrors the new optional fields. Tests: admin_test.go's new table-driven TestAdminUsage_GroupAccessLists covers a restricted group's exact lists, an unrestricted group's empty/omitted lists, and that a user/total row never carries them.
This commit is contained in:
@@ -415,11 +415,25 @@ func (g *Gateway) serveAdminOverview(w http.ResponseWriter) {
|
|||||||
// tokensPerDay/tokensPerMonth pair (v0.2 data-layer task, tokens-in/
|
// tokensPerDay/tokensPerMonth pair (v0.2 data-layer task, tokens-in/
|
||||||
// tokens-out split) — a client wanting the combined total sums the two
|
// tokens-out split) — a client wanting the combined total sums the two
|
||||||
// itself.
|
// itself.
|
||||||
|
//
|
||||||
|
// Providers/Models/MCPServers/Agents (group-access-display task) are set
|
||||||
|
// only for a group entry — a user or the total entry never populates them,
|
||||||
|
// same convention as GroupName's own "user-only" field above, mirrored.
|
||||||
|
// Each carries the group's GroupConfig list exactly as configured: omitted
|
||||||
|
// (nil) means every provider/model/MCP server/agent is allowed
|
||||||
|
// (group.allowsX's own empty-list-matches-anything contract, auth.go) —
|
||||||
|
// this is never resolved/expanded to the full catalog here, so the
|
||||||
|
// dashboard can tell "explicitly restricted to these N" apart from
|
||||||
|
// "unrestricted" without the payload growing with catalog size.
|
||||||
type adminUsageEntryView struct {
|
type adminUsageEntryView struct {
|
||||||
Limits *LimitsConfig `json:"limits,omitempty"`
|
Limits *LimitsConfig `json:"limits,omitempty"`
|
||||||
Kind string `json:"kind"`
|
Kind string `json:"kind"`
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
GroupName string `json:"groupName,omitempty"`
|
GroupName string `json:"groupName,omitempty"`
|
||||||
|
Providers []string `json:"providers,omitempty"`
|
||||||
|
Models []string `json:"models,omitempty"`
|
||||||
|
MCPServers []string `json:"mcpServers,omitempty"`
|
||||||
|
Agents []string `json:"agents,omitempty"`
|
||||||
RequestsPerMinute int64 `json:"requestsPerMinute"`
|
RequestsPerMinute int64 `json:"requestsPerMinute"`
|
||||||
RequestsPerDay int64 `json:"requestsPerDay"`
|
RequestsPerDay int64 `json:"requestsPerDay"`
|
||||||
TokensInPerDay int64 `json:"tokensInPerDay"`
|
TokensInPerDay int64 `json:"tokensInPerDay"`
|
||||||
@@ -500,6 +514,10 @@ func (g *Gateway) buildAdminUsage() adminUsageResponse {
|
|||||||
groups := make([]adminUsageEntryView, len(groupUsage))
|
groups := make([]adminUsageEntryView, len(groupUsage))
|
||||||
for i, su := range groupUsage {
|
for i, su := range groupUsage {
|
||||||
groups[i] = usageEntryView(su, groupSummaries[i].limits)
|
groups[i] = usageEntryView(su, groupSummaries[i].limits)
|
||||||
|
groups[i].Providers = groupSummaries[i].providers
|
||||||
|
groups[i].Models = groupSummaries[i].models
|
||||||
|
groups[i].MCPServers = groupSummaries[i].mcpServers
|
||||||
|
groups[i].Agents = groupSummaries[i].agents
|
||||||
}
|
}
|
||||||
total := usageEntryView(totalUsage, nil)
|
total := usageEntryView(totalUsage, nil)
|
||||||
|
|
||||||
|
|||||||
@@ -496,6 +496,97 @@ func findUsageEntry(t *testing.T, entries []adminUsageEntryView, id string) admi
|
|||||||
return adminUsageEntryView{}
|
return adminUsageEntryView{}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestAdminUsage_GroupAccessLists proves GET /admin/api/usage's per-group
|
||||||
|
// rows (group-access-display task) echo GroupConfig's Providers/Models/
|
||||||
|
// MCPServers/Agents exactly as configured — no server-side expansion to the
|
||||||
|
// full provider/model catalog — and that a group with none of those set
|
||||||
|
// carries empty/omitted lists rather than a resolved "everything" set,
|
||||||
|
// matching group.allowsX's own empty-means-all contract (auth.go). A user
|
||||||
|
// row and the synthetic total row must never carry these fields at all —
|
||||||
|
// they are group-only, same convention as GroupName's own user-only field.
|
||||||
|
func TestAdminUsage_GroupAccessLists(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
cfg := CreateConfig()
|
||||||
|
cfg.Admin = &AdminConfig{Enabled: true}
|
||||||
|
cfg.Providers = map[string]*ProviderConfig{
|
||||||
|
"alpha": {Type: "openai", BaseURL: "http://alpha.invalid", APIKey: "sk-alpha", Models: []string{"a-model-1"}},
|
||||||
|
}
|
||||||
|
cfg.MCPServers = map[string]*TargetConfig{"srv1": {URL: "http://srv1.invalid"}}
|
||||||
|
cfg.Agents = map[string]*AgentConfig{"agent1": {URL: "http://agent1.invalid"}}
|
||||||
|
cfg.Groups = map[string]*GroupConfig{
|
||||||
|
"restricted": {
|
||||||
|
Providers: []string{"alpha"},
|
||||||
|
Models: []string{"alpha/a-model-1", "z-model"},
|
||||||
|
MCPServers: []string{"srv1"},
|
||||||
|
Agents: []string{"agent1"},
|
||||||
|
},
|
||||||
|
"open": {}, // no Providers/Models/MCPServers/Agents set: unrestricted
|
||||||
|
}
|
||||||
|
cfg.Users = &UsersConfig{Inline: []*UserConfig{
|
||||||
|
{Name: "admin1", Group: "open", APIKey: "sk-admin1", Admin: true},
|
||||||
|
}}
|
||||||
|
h, _ := newAdminGatewayHandle(t, cfg)
|
||||||
|
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
h.ServeHTTP(rec, adminRequest(http.MethodGet, adminUsagePath, "sk-admin1"))
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d, want 200, body=%s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
var got adminUsageResponse
|
||||||
|
if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil {
|
||||||
|
t.Fatalf("decode: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
group string
|
||||||
|
wantProviders []string
|
||||||
|
wantModels []string
|
||||||
|
wantMCPServers []string
|
||||||
|
wantAgents []string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "restricted group carries its exact configured lists",
|
||||||
|
group: "restricted",
|
||||||
|
wantProviders: []string{"alpha"},
|
||||||
|
wantModels: []string{"alpha/a-model-1", "z-model"},
|
||||||
|
wantMCPServers: []string{"srv1"},
|
||||||
|
wantAgents: []string{"agent1"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "unrestricted group carries empty/omitted lists",
|
||||||
|
group: "open",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, tc := range tests {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
entry := findUsageEntry(t, got.Groups, tc.group)
|
||||||
|
if !slices.Equal(entry.Providers, tc.wantProviders) {
|
||||||
|
t.Errorf("providers = %v, want %v", entry.Providers, tc.wantProviders)
|
||||||
|
}
|
||||||
|
if !slices.Equal(entry.Models, tc.wantModels) {
|
||||||
|
t.Errorf("models = %v, want %v", entry.Models, tc.wantModels)
|
||||||
|
}
|
||||||
|
if !slices.Equal(entry.MCPServers, tc.wantMCPServers) {
|
||||||
|
t.Errorf("mcpServers = %v, want %v", entry.MCPServers, tc.wantMCPServers)
|
||||||
|
}
|
||||||
|
if !slices.Equal(entry.Agents, tc.wantAgents) {
|
||||||
|
t.Errorf("agents = %v, want %v", entry.Agents, tc.wantAgents)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
admin1 := findUsageEntry(t, got.Users, "admin1")
|
||||||
|
if admin1.Providers != nil || admin1.Models != nil || admin1.MCPServers != nil || admin1.Agents != nil {
|
||||||
|
t.Errorf("user entry must never carry group access lists, got providers=%v models=%v mcpServers=%v agents=%v",
|
||||||
|
admin1.Providers, admin1.Models, admin1.MCPServers, admin1.Agents)
|
||||||
|
}
|
||||||
|
if got.Total.Providers != nil || got.Total.Models != nil || got.Total.MCPServers != nil || got.Total.Agents != nil {
|
||||||
|
t.Errorf("total entry must never carry group access lists, got providers=%v models=%v mcpServers=%v agents=%v",
|
||||||
|
got.Total.Providers, got.Total.Models, got.Total.MCPServers, got.Total.Agents)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// TestLimiterCurrentUsage_StoreDown drives limiter.currentUsage's
|
// TestLimiterCurrentUsage_StoreDown drives limiter.currentUsage's
|
||||||
// fail-closed path directly: a configured store whose every operation
|
// fail-closed path directly: a configured store whose every operation
|
||||||
// errors, with failOpen false, must report storeDown and zero values
|
// errors, with failOpen false, must report storeDown and zero values
|
||||||
|
|||||||
@@ -334,10 +334,19 @@ type userSummary struct {
|
|||||||
|
|
||||||
// groupSummary mirrors userSummary for one configured group, plus its
|
// groupSummary mirrors userSummary for one configured group, plus its
|
||||||
// current member count (how many active users, inline or file-sourced,
|
// current member count (how many active users, inline or file-sourced,
|
||||||
// currently reference it).
|
// currently reference it) and its configured access lists — providers,
|
||||||
|
// models, mcpServers, agents — carried through exactly as group.allowsX
|
||||||
|
// itself reads them (group-access-display task): nil/empty means every
|
||||||
|
// provider/model/MCP server/agent is allowed (matchesGlob's own contract,
|
||||||
|
// auth.go), never expanded to the full catalog here — the admin dashboard
|
||||||
|
// echoes the configured glob list, not a resolved membership set.
|
||||||
type groupSummary struct {
|
type groupSummary struct {
|
||||||
limits *LimitsConfig
|
limits *LimitsConfig
|
||||||
name string
|
name string
|
||||||
|
providers []string
|
||||||
|
models []string
|
||||||
|
mcpServers []string
|
||||||
|
agents []string
|
||||||
memberCount int
|
memberCount int
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -359,7 +368,15 @@ func (a *authStore) snapshot() ([]userSummary, []groupSummary) {
|
|||||||
|
|
||||||
groups := make([]groupSummary, 0, len(a.groups))
|
groups := make([]groupSummary, 0, len(a.groups))
|
||||||
for name, grp := range a.groups {
|
for name, grp := range a.groups {
|
||||||
groups = append(groups, groupSummary{limits: grp.limits, name: name, memberCount: memberCounts[name]})
|
groups = append(groups, groupSummary{
|
||||||
|
limits: grp.limits,
|
||||||
|
name: name,
|
||||||
|
providers: grp.providers,
|
||||||
|
models: grp.models,
|
||||||
|
mcpServers: grp.mcpServers,
|
||||||
|
agents: grp.agents,
|
||||||
|
memberCount: memberCounts[name],
|
||||||
|
})
|
||||||
}
|
}
|
||||||
sort.Slice(groups, func(i, j int) bool { return groups[i].name < groups[j].name })
|
sort.Slice(groups, func(i, j int) bool { return groups[i].name < groups[j].name })
|
||||||
|
|
||||||
|
|||||||
@@ -3,9 +3,11 @@ import type { SortingState } from '@tanstack/vue-table'
|
|||||||
import { getCoreRowModel, getSortedRowModel, useVueTable } from '@tanstack/vue-table'
|
import { getCoreRowModel, getSortedRowModel, useVueTable } from '@tanstack/vue-table'
|
||||||
import { computed, reactive, ref, watch } from 'vue'
|
import { computed, reactive, ref, watch } from 'vue'
|
||||||
|
|
||||||
|
import ModelChip from '@/components/ModelChip.vue'
|
||||||
import SearchInput from '@/components/SearchInput.vue'
|
import SearchInput from '@/components/SearchInput.vue'
|
||||||
import SortHeaderButton from '@/components/SortHeaderButton.vue'
|
import SortHeaderButton from '@/components/SortHeaderButton.vue'
|
||||||
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from '@/components/ui/accordion'
|
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from '@/components/ui/accordion'
|
||||||
|
import { Badge } from '@/components/ui/badge'
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||||
import { valueUpdater } from '@/components/ui/table'
|
import { valueUpdater } from '@/components/ui/table'
|
||||||
import UsageTable from '@/components/UsageTable.vue'
|
import UsageTable from '@/components/UsageTable.vue'
|
||||||
@@ -284,6 +286,37 @@ watch(userQuery, (value) => {
|
|||||||
</span>
|
</span>
|
||||||
</AccordionTrigger>
|
</AccordionTrigger>
|
||||||
<AccordionContent>
|
<AccordionContent>
|
||||||
|
<!--
|
||||||
|
Access block (group-access-display task): the group's
|
||||||
|
configured provider/model access, display-only — not part
|
||||||
|
of the user/group search filter above (row.original.providers/
|
||||||
|
models are plain display data, never touched by
|
||||||
|
groupMatches/userMatches in lib/usage-search.ts).
|
||||||
|
-->
|
||||||
|
<div class="mb-3 grid gap-3 sm:grid-cols-2">
|
||||||
|
<div>
|
||||||
|
<p class="mb-1.5 text-xs font-medium text-muted-foreground">Providers</p>
|
||||||
|
<div v-if="row.original.providers?.length" class="flex flex-wrap gap-1.5">
|
||||||
|
<Badge
|
||||||
|
v-for="providerName in row.original.providers"
|
||||||
|
:key="providerName"
|
||||||
|
as="span"
|
||||||
|
variant="secondary"
|
||||||
|
class="font-normal"
|
||||||
|
>
|
||||||
|
{{ providerName }}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
<p v-else class="text-sm text-muted-foreground">All providers</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p class="mb-1.5 text-xs font-medium text-muted-foreground">Models</p>
|
||||||
|
<div v-if="row.original.models?.length" class="flex flex-wrap gap-1.5">
|
||||||
|
<ModelChip v-for="modelId in row.original.models" :key="modelId" :id="modelId" />
|
||||||
|
</div>
|
||||||
|
<p v-else class="text-sm text-muted-foreground">All models</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<dl class="mb-3 grid grid-cols-2 gap-x-6 gap-y-1.5 text-sm sm:grid-cols-4">
|
<dl class="mb-3 grid grid-cols-2 gap-x-6 gap-y-1.5 text-sm sm:grid-cols-4">
|
||||||
<div>
|
<div>
|
||||||
<dt class="text-xs text-muted-foreground">Limits</dt>
|
<dt class="text-xs text-muted-foreground">Limits</dt>
|
||||||
|
|||||||
@@ -67,6 +67,19 @@ export interface AdminUsageEntryView {
|
|||||||
kind: string
|
kind: string
|
||||||
id: string
|
id: string
|
||||||
groupName?: string
|
groupName?: string
|
||||||
|
/**
|
||||||
|
* The group's configured access lists (admin.go: adminUsageEntryView's
|
||||||
|
* Providers/Models/MCPServers/Agents) — set only on a group entry (kind
|
||||||
|
* === 'group'), never on a user or the total entry, mirroring groupName's
|
||||||
|
* own user-only convention above. Undefined/empty means unrestricted
|
||||||
|
* (GroupConfig's own empty-means-all semantics, llmgateway.go) — this is
|
||||||
|
* the group's configured glob list exactly as written, never expanded to
|
||||||
|
* the full provider/model catalog.
|
||||||
|
*/
|
||||||
|
providers?: string[]
|
||||||
|
models?: string[]
|
||||||
|
mcpServers?: string[]
|
||||||
|
agents?: string[]
|
||||||
requestsPerMinute: number
|
requestsPerMinute: number
|
||||||
requestsPerDay: number
|
requestsPerDay: number
|
||||||
tokensInPerDay: number
|
tokensInPerDay: number
|
||||||
|
|||||||
Reference in New Issue
Block a user