mirror of
https://github.com/lukaszraczylo/traefik-llmgateway.git
synced 2026-10-19 05:47:48 +00:00
feat(webui): user and group search filter on usage and charts views
Add a "Filter users or groups" search input to UsageView, mirroring OverviewView's model search (same Input component, icon, and clear-button styling). Case-insensitive, trimmed substring match: - A user matches when its own name contains the query. - A group matches when its own name contains the query OR it has at least one matching member. Groups accordion shows only matching groups, sorted through the existing Groups toolbar unchanged. A group that matches only via a member auto-expands (using the same tested override semantics as Overview's provider search); a group matched by its own name is not force-expanded, since the match is already visible on its collapsed trigger row. The nested members table inside an expanded group shows every member when the group matched by name, and only the matching members when it matched via a member. The flat Users table filters to matching users, plus every member of a name-matched group. The Total card and ChartsView's chart rendering are untouched by the filter. ChartsView's scope picker (Select) gets the identical search input, filtering the user/group option list it offers. "Total" always stays in the list, and the currently selected scope is always kept in the list even if a new query would otherwise hide it, so an active chart is never torn down or re-labeled by narrowing the picker. Generalize lib/provider-expand.ts into lib/search-expand.ts: the same two-set (manuallyExpanded/manuallyCollapsed) auto-expand/override state machine now drives both Overview's providers and Usage's groups, keyed on generic string ids instead of provider names specifically. OverviewView.vue is updated to the renamed computeExpandedItems/toggleItemExpand exports; its own behavior is unchanged. The moved spec (search-expand.spec.ts) keeps all 9 original cases plus 2 new ones pinning the same semantics against non-provider (group-shaped) ids. Add lib/usage-search.ts with the shared user/group matching helpers (userMatches, groupNameMatches, membersOfGroup, matchingMembersOfGroup, groupMatches) so UsageView and ChartsView share one matching implementation, with its own vitest spec. UsageTable.vue gains an optional emptyMessage prop (threaded to DataTable) so UsageView can show a "no users match ..."/"no groups match ..." message under an active filter, matching Overview's own alias-search empty-state pattern. No backend/Go logic changes; filtering is client-side over the already-polled GET /admin/api/usage response.
This commit is contained in:
@@ -0,0 +1,85 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { groupMatches, groupNameMatches, matchingMembersOfGroup, membersOfGroup, userMatches } from './usage-search'
|
||||
import type { AdminUsageEntryView } from '@/types/api'
|
||||
|
||||
/** entry builds a minimal AdminUsageEntryView — only id/groupName matter to this module, the rest are never read. */
|
||||
function entry(id: string, groupName?: string): AdminUsageEntryView {
|
||||
return {
|
||||
kind: groupName === undefined ? 'group' : 'user',
|
||||
id,
|
||||
groupName,
|
||||
requestsPerMinute: 0,
|
||||
requestsPerDay: 0,
|
||||
tokensInPerDay: 0,
|
||||
tokensOutPerDay: 0,
|
||||
tokensInPerMonth: 0,
|
||||
tokensOutPerMonth: 0,
|
||||
costPerDayMicroUsd: 0,
|
||||
costPerMonthMicroUsd: 0,
|
||||
}
|
||||
}
|
||||
|
||||
const alice = entry('alice', 'team-a')
|
||||
const bob = entry('bob', 'team-a')
|
||||
const carol = entry('carol', 'team-b')
|
||||
const teamA = entry('team-a')
|
||||
const teamB = entry('team-b')
|
||||
const users = [alice, bob, carol]
|
||||
|
||||
describe('userMatches', () => {
|
||||
it('matches a case-insensitive substring of the id (query pre-lowercased by the caller)', () => {
|
||||
expect(userMatches(alice, 'lic')).toBe(true)
|
||||
})
|
||||
|
||||
it('does not match an unrelated query', () => {
|
||||
expect(userMatches(alice, 'zzz')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('groupNameMatches', () => {
|
||||
it('matches on the group id alone, ignoring members entirely', () => {
|
||||
expect(groupNameMatches(teamA, 'team-a')).toBe(true)
|
||||
})
|
||||
|
||||
it('does not match a query that only a member would match', () => {
|
||||
expect(groupNameMatches(teamA, 'alice')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('membersOfGroup', () => {
|
||||
it('joins users to a group on groupName === group.id', () => {
|
||||
expect(membersOfGroup(teamA, users)).toEqual([alice, bob])
|
||||
expect(membersOfGroup(teamB, users)).toEqual([carol])
|
||||
})
|
||||
})
|
||||
|
||||
describe('matchingMembersOfGroup', () => {
|
||||
it('returns only the group members that themselves match query', () => {
|
||||
expect(matchingMembersOfGroup(teamA, users, 'alice')).toEqual([alice])
|
||||
})
|
||||
|
||||
it('returns an empty array when no member matches', () => {
|
||||
expect(matchingMembersOfGroup(teamA, users, 'zzz')).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('groupMatches', () => {
|
||||
it('matches via the group’s own name, with no matching member', () => {
|
||||
expect(groupMatches(teamA, users, 'team-a')).toBe(true)
|
||||
})
|
||||
|
||||
it('matches via a member, with no name match', () => {
|
||||
expect(groupMatches(teamA, users, 'alice')).toBe(true)
|
||||
})
|
||||
|
||||
it('does not match when neither the name nor any member matches', () => {
|
||||
expect(groupMatches(teamA, users, 'zzz')).toBe(false)
|
||||
})
|
||||
|
||||
it('a group with no members only matches by name', () => {
|
||||
const lonely = entry('lonely-group')
|
||||
expect(groupMatches(lonely, users, 'lonely')).toBe(true)
|
||||
expect(groupMatches(lonely, users, 'alice')).toBe(false)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user