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:
2026-08-21 12:16:57 +01:00
parent dded3a56fe
commit d6b9879813
11 changed files with 534 additions and 149 deletions
-83
View File
@@ -1,83 +0,0 @@
/**
* Pure expand/collapse state for OverviewView.vue's provider-model
* accordion, extracted out of the component so the exact toggle sequence
* a review flagged (see toggleProviderExpand's own comment) is tested in
* isolation, without mounting Vue — see provider-expand.spec.ts (vitest),
* which exercises this module directly, including the full flagged
* sequence.
*
* Two sets track state:
* - manuallyExpanded: providers the user explicitly opened, independent
* of any search query. This is the state a cleared search returns to.
* - manuallyCollapsed: providers the user explicitly closed WHILE a
* search query was auto-expanding them. Only ever consulted while a
* query is active (computeExpandedProviders below) — once the query
* clears, effective state is manuallyExpanded alone, so entries here
* become inert. clearExpandOverrides drops them anyway, so a stale
* collapse from one search session cannot silently suppress a match in
* an unrelated later one.
*/
export interface ExpandState {
manuallyExpanded: Set<string>
manuallyCollapsed: Set<string>
}
/**
* computeExpandedProviders returns the effective set of expanded
* provider names: manuallyExpanded verbatim when no query is active;
* with a query active, manuallyExpanded plus every name in
* matchingNames that is not in manuallyCollapsed (auto-expand every
* match, unless the user explicitly closed that one).
*/
export function computeExpandedProviders(state: ExpandState, hasQuery: boolean, matchingNames: string[]): Set<string> {
if (!hasQuery) return new Set(state.manuallyExpanded)
const expanded = new Set(state.manuallyExpanded)
for (const name of matchingNames) {
if (!state.manuallyCollapsed.has(name)) expanded.add(name)
}
return expanded
}
/**
* toggleProviderExpand mutates state for one provider, given whether it
* is CURRENTLY effectively expanded (computeExpandedProviders' own
* result for that name — the caller passes this, rather than the
* function re-deriving it, so callers stay in charge of what "current"
* means for their own render).
*
* This is the exact fix for a review-flagged bug: the original
* implementation toggled manuallyExpanded.has(name) directly — the RAW
* manual flag, not the effective (possibly auto-expanded) one. Clicking
* a row that was only auto-expanded (matching the search, never
* manually opened) read manuallyExpanded.has(name) as false and ADDED
* it to manuallyExpanded — invisible immediately (it was already
* showing via auto-expand), but wrongly left it expanded after the
* query cleared, since manuallyExpanded now contained a provider the
* user never actually chose to open. Toggling against the EFFECTIVE
* state fixes this in both directions:
* - Expanded only via auto-match, clicked to close: manuallyExpanded
* never gains the entry (nothing to remove); manuallyCollapsed gains
* it, suppressing the auto-expand for as long as the query stays
* active. Clearing the query drops back to manuallyExpanded, which
* never had it — correctly collapsed.
* - Expanded via a manual open from BEFORE a search started, still
* matches, clicked to close during the search: manuallyExpanded loses
* it (the user's own most recent action wins) and manuallyCollapsed
* gains it, so it also stays closed after the query clears — treating
* the in-search click as the user's current intent, not the older,
* pre-search one.
*/
export function toggleProviderExpand(state: ExpandState, name: string, currentlyExpanded: boolean): void {
if (currentlyExpanded) {
state.manuallyExpanded.delete(name)
state.manuallyCollapsed.add(name)
} else {
state.manuallyCollapsed.delete(name)
state.manuallyExpanded.add(name)
}
}
/** clearExpandOverrides drops every manuallyCollapsed entry — called when the search query becomes empty, so a suppression from one search session never carries into an unrelated later one (manuallyCollapsed is otherwise never consulted outside an active query anyway; this only matters for a FUTURE query). */
export function clearExpandOverrides(state: ExpandState): void {
state.manuallyCollapsed.clear()
}
@@ -3,45 +3,45 @@ import { describe, expect, it } from 'vitest'
import {
type ExpandState,
clearExpandOverrides,
computeExpandedProviders,
toggleProviderExpand,
} from './provider-expand'
computeExpandedItems,
toggleItemExpand,
} from './search-expand'
/** newState is a fresh ExpandState — every test starts from an empty, un-searched panel. */
function newState(): ExpandState {
return { manuallyExpanded: new Set(), manuallyCollapsed: new Set() }
}
describe('computeExpandedProviders', () => {
describe('computeExpandedItems', () => {
it('returns manuallyExpanded verbatim when there is no active query', () => {
const state = newState()
state.manuallyExpanded.add('openai')
expect(computeExpandedProviders(state, false, ['openai', 'anthropic'])).toEqual(new Set(['openai']))
expect(computeExpandedItems(state, false, ['openai', 'anthropic'])).toEqual(new Set(['openai']))
})
it('auto-expands every matching name while a query is active', () => {
it('auto-expands every matching id while a query is active', () => {
const state = newState()
expect(computeExpandedProviders(state, true, ['openai', 'anthropic'])).toEqual(new Set(['openai', 'anthropic']))
expect(computeExpandedItems(state, true, ['openai', 'anthropic'])).toEqual(new Set(['openai', 'anthropic']))
})
it('unions manuallyExpanded with matches while a query is active', () => {
const state = newState()
state.manuallyExpanded.add('gemini') // manually opened before the search started, does not match it
expect(computeExpandedProviders(state, true, ['openai'])).toEqual(new Set(['gemini', 'openai']))
expect(computeExpandedItems(state, true, ['openai'])).toEqual(new Set(['gemini', 'openai']))
})
it('suppresses an auto-match the user explicitly collapsed', () => {
const state = newState()
state.manuallyCollapsed.add('openai')
expect(computeExpandedProviders(state, true, ['openai', 'anthropic'])).toEqual(new Set(['anthropic']))
expect(computeExpandedItems(state, true, ['openai', 'anthropic'])).toEqual(new Set(['anthropic']))
})
})
describe('toggleProviderExpand', () => {
describe('toggleItemExpand', () => {
it('opening (currentlyExpanded=false) adds to manuallyExpanded and clears any collapse override', () => {
const state = newState()
state.manuallyCollapsed.add('openai')
toggleProviderExpand(state, 'openai', false)
toggleItemExpand(state, 'openai', false)
expect(state.manuallyExpanded.has('openai')).toBe(true)
expect(state.manuallyCollapsed.has('openai')).toBe(false)
})
@@ -49,7 +49,7 @@ describe('toggleProviderExpand', () => {
it('closing (currentlyExpanded=true) removes from manuallyExpanded and adds a collapse override', () => {
const state = newState()
state.manuallyExpanded.add('openai')
toggleProviderExpand(state, 'openai', true)
toggleItemExpand(state, 'openai', true)
expect(state.manuallyExpanded.has('openai')).toBe(false)
expect(state.manuallyCollapsed.has('openai')).toBe(true)
})
@@ -66,53 +66,86 @@ describe('clearExpandOverrides', () => {
})
})
describe('the review-flagged sequence: an auto-matched (never manually opened) provider, clicked closed', () => {
describe('the review-flagged sequence: an auto-matched (never manually opened) item, clicked closed', () => {
it('never enters manuallyExpanded, and stays collapsed after the query clears', () => {
const state = newState()
const matches = ['openai']
// 1. Search starts matching "openai" — it shows via auto-expand only,
// manuallyExpanded is still empty.
let expanded = computeExpandedProviders(state, true, matches)
let expanded = computeExpandedItems(state, true, matches)
expect(expanded.has('openai')).toBe(true)
expect(state.manuallyExpanded.has('openai')).toBe(false)
// 2. User clicks it closed while it's only auto-expanded. The RAW bug
// this module exists to fix: reading manuallyExpanded.has('openai')
// directly (false) would have ADDED it to manuallyExpanded instead
// of suppressing the auto-expand. toggleProviderExpand takes the
// of suppressing the auto-expand. toggleItemExpand takes the
// EFFECTIVE current state (expanded.has('openai') === true) instead.
toggleProviderExpand(state, 'openai', expanded.has('openai'))
toggleItemExpand(state, 'openai', expanded.has('openai'))
expect(state.manuallyExpanded.has('openai')).toBe(false)
expect(state.manuallyCollapsed.has('openai')).toBe(true)
// 3. Still searching: the override suppresses the auto-expand.
expanded = computeExpandedProviders(state, true, matches)
expanded = computeExpandedItems(state, true, matches)
expect(expanded.has('openai')).toBe(false)
// 4. Query clears: a stale override must never carry into a later,
// unrelated search.
clearExpandOverrides(state)
expanded = computeExpandedProviders(state, false, [])
expanded = computeExpandedItems(state, false, [])
expect(expanded.has('openai')).toBe(false)
})
})
describe('a provider manually opened before search, still matches, clicked closed during search', () => {
describe('an item manually opened before search, still matches, clicked closed during search', () => {
it("the user's in-search click wins, and it stays closed after the query clears too", () => {
const state = newState()
state.manuallyExpanded.add('openai') // opened before any search existed
const matches = ['openai']
let expanded = computeExpandedProviders(state, true, matches)
let expanded = computeExpandedItems(state, true, matches)
expect(expanded.has('openai')).toBe(true)
toggleProviderExpand(state, 'openai', expanded.has('openai'))
toggleItemExpand(state, 'openai', expanded.has('openai'))
expect(state.manuallyExpanded.has('openai')).toBe(false)
expect(state.manuallyCollapsed.has('openai')).toBe(true)
clearExpandOverrides(state)
expanded = computeExpandedProviders(state, false, [])
expanded = computeExpandedItems(state, false, [])
expect(expanded.has('openai')).toBe(false)
})
})
// This module is domain-agnostic on what an "id" represents — it started as
// OverviewView.vue's provider names, but UsageView.vue now feeds it group
// ids too (see that view's own doc comment: matchingIds there is every
// group that matches ONLY via a member, never via its own name). These two
// cases exist to pin that the module behaves identically for that second
// domain, not because anything here differs by id shape.
describe('generic ids (Usage group ids, not just Overview provider names)', () => {
it('auto-expands and can be collapsed/overridden identically for a group id', () => {
const state = newState()
const matches = ['team-a']
let expanded = computeExpandedItems(state, true, matches)
expect(expanded.has('team-a')).toBe(true)
toggleItemExpand(state, 'team-a', expanded.has('team-a'))
expect(state.manuallyExpanded.has('team-a')).toBe(false)
expect(state.manuallyCollapsed.has('team-a')).toBe(true)
expanded = computeExpandedItems(state, true, matches)
expect(expanded.has('team-a')).toBe(false)
clearExpandOverrides(state)
expanded = computeExpandedItems(state, false, [])
expect(expanded.has('team-a')).toBe(false)
})
it('a group id manually opened stays open when the query clears', () => {
const state = newState()
state.manuallyExpanded.add('team-a')
expect(computeExpandedItems(state, false, [])).toEqual(new Set(['team-a']))
})
})
+93
View File
@@ -0,0 +1,93 @@
/**
* Pure expand/collapse state for a search-filterable shadcn-vue Accordion —
* shared by OverviewView.vue (providers) and UsageView.vue (groups), and
* generalized (originally provider-expand.ts) so both consume ONE tested
* implementation instead of two copies of the same logic (vue.md: "if
* you've written it twice, you owe an abstraction"). Extracted out of the
* component so the exact toggle sequence a review flagged (see
* toggleItemExpand's own comment) is tested in isolation, without mounting
* Vue — see search-expand.spec.ts (vitest), which exercises this module
* directly, including the full flagged sequence.
*
* Two sets track state, keyed on each accordion item's own string id (a
* provider name in Overview, a group id in Usage — this module has no
* opinion on what the id means or on what counts as a "match"; callers
* decide that and pass the resulting id list in):
* - manuallyExpanded: items the user explicitly opened, independent of any
* search query. This is the state a cleared search returns to.
* - manuallyCollapsed: items the user explicitly closed WHILE a search
* query was auto-expanding them. Only ever consulted while a query is
* active (computeExpandedItems below) — once the query clears, effective
* state is manuallyExpanded alone, so entries here become inert.
* clearExpandOverrides drops them anyway, so a stale collapse from one
* search session cannot silently suppress a match in an unrelated later
* one.
*/
export interface ExpandState {
manuallyExpanded: Set<string>
manuallyCollapsed: Set<string>
}
/**
* computeExpandedItems returns the effective set of expanded item ids:
* manuallyExpanded verbatim when no query is active; with a query active,
* manuallyExpanded plus every id in matchingIds that is not in
* manuallyCollapsed (auto-expand every match, unless the user explicitly
* closed that one). The caller decides what "matching" means for its own
* view — OverviewView.vue passes every provider with a matching model;
* UsageView.vue passes every group that matches ONLY via a member, not via
* its own name (see that view's own doc comment for why a name match
* alone does not force-expand there).
*/
export function computeExpandedItems(state: ExpandState, hasQuery: boolean, matchingIds: string[]): Set<string> {
if (!hasQuery) return new Set(state.manuallyExpanded)
const expanded = new Set(state.manuallyExpanded)
for (const id of matchingIds) {
if (!state.manuallyCollapsed.has(id)) expanded.add(id)
}
return expanded
}
/**
* toggleItemExpand mutates state for one item, given whether it is
* CURRENTLY effectively expanded (computeExpandedItems' own result for
* that id — the caller passes this, rather than the function re-deriving
* it, so callers stay in charge of what "current" means for their own
* render).
*
* This is the exact fix for a review-flagged bug: the original
* implementation toggled manuallyExpanded.has(id) directly — the RAW
* manual flag, not the effective (possibly auto-expanded) one. Clicking a
* row that was only auto-expanded (matching the search, never manually
* opened) read manuallyExpanded.has(id) as false and ADDED it to
* manuallyExpanded — invisible immediately (it was already showing via
* auto-expand), but wrongly left it expanded after the query cleared,
* since manuallyExpanded now contained an item the user never actually
* chose to open. Toggling against the EFFECTIVE state fixes this in both
* directions:
* - Expanded only via auto-match, clicked to close: manuallyExpanded never
* gains the entry (nothing to remove); manuallyCollapsed gains it,
* suppressing the auto-expand for as long as the query stays active.
* Clearing the query drops back to manuallyExpanded, which never had it
* — correctly collapsed.
* - Expanded via a manual open from BEFORE a search started, still
* matches, clicked to close during the search: manuallyExpanded loses it
* (the user's own most recent action wins) and manuallyCollapsed gains
* it, so it also stays closed after the query clears — treating the
* in-search click as the user's current intent, not the older,
* pre-search one.
*/
export function toggleItemExpand(state: ExpandState, id: string, currentlyExpanded: boolean): void {
if (currentlyExpanded) {
state.manuallyExpanded.delete(id)
state.manuallyCollapsed.add(id)
} else {
state.manuallyCollapsed.delete(id)
state.manuallyExpanded.add(id)
}
}
/** clearExpandOverrides drops every manuallyCollapsed entry — called when the search query becomes empty, so a suppression from one search session never carries into an unrelated later one (manuallyCollapsed is otherwise never consulted outside an active query anyway; this only matters for a FUTURE query). */
export function clearExpandOverrides(state: ExpandState): void {
state.manuallyCollapsed.clear()
}
+85
View File
@@ -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)
})
})
+58
View File
@@ -0,0 +1,58 @@
import type { AdminUsageEntryView } from '@/types/api'
/**
* Shared matching helpers for the "Filter users or groups" search
* UsageView.vue and ChartsView.vue both offer (operator directive:
* identical semantics, ONE implementation — no second copy of this logic).
*
* Every `query` parameter here is expected to already be trimmed and
* lowercased by the caller, mirroring OverviewView.vue's own
* normalizedQuery convention (modelMatches/aliasMatches there never
* re-normalize either) — these functions stay pure string/array
* operations, with the caller owning the single source of truth for "is a
* query active at all".
*/
/** userMatches: a user matches when its own id (display name) contains query. */
export function userMatches(user: Pick<AdminUsageEntryView, 'id'>, query: string): boolean {
return user.id.toLowerCase().includes(query)
}
/** groupNameMatches: a group matches by its own id (display name) alone, ignoring its members. */
export function groupNameMatches(group: Pick<AdminUsageEntryView, 'id'>, query: string): boolean {
return group.id.toLowerCase().includes(query)
}
/**
* membersOfGroup: group's own member user rows, joined on
* `groupName === group.id` — a user's row already carries its own group
* name (admin.go's adminUsageEntryView.GroupName, set in buildAdminUsage),
* so this is a plain client-side join against the already-polled
* GET /admin/api/usage response, no extra fetch. Shared so UsageView.vue
* and ChartsView.vue never re-derive this join differently (it replaces
* UsageView.vue's own former local membersOf helper).
*/
export function membersOfGroup(group: Pick<AdminUsageEntryView, 'id'>, users: AdminUsageEntryView[]): AdminUsageEntryView[] {
return users.filter((u) => u.groupName === group.id)
}
/** matchingMembersOfGroup: group's own members that themselves match query. */
export function matchingMembersOfGroup(
group: Pick<AdminUsageEntryView, 'id'>,
users: AdminUsageEntryView[],
query: string,
): AdminUsageEntryView[] {
return membersOfGroup(group, users).filter((u) => userMatches(u, query))
}
/**
* groupMatches: a group matches if its own name contains query OR it has
* at least one matching member (binding search semantics). Callers that
* need to distinguish WHICH of the two reasons a group matched — to decide
* whether to show all of its members or only the matching ones — use
* groupNameMatches and matchingMembersOfGroup directly instead (see
* UsageView.vue's own doc comment for that distinction).
*/
export function groupMatches(group: Pick<AdminUsageEntryView, 'id'>, users: AdminUsageEntryView[], query: string): boolean {
return groupNameMatches(group, query) || matchingMembersOfGroup(group, users, query).length > 0
}