@@ -208,8 +345,9 @@ const expandedGroups = ref([])
diff --git a/webui/src/lib/provider-expand.ts b/webui/src/lib/provider-expand.ts
deleted file mode 100644
index a14c1e1..0000000
--- a/webui/src/lib/provider-expand.ts
+++ /dev/null
@@ -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
- manuallyCollapsed: Set
-}
-
-/**
- * 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 {
- 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()
-}
diff --git a/webui/src/lib/provider-expand.spec.ts b/webui/src/lib/search-expand.spec.ts
similarity index 55%
rename from webui/src/lib/provider-expand.spec.ts
rename to webui/src/lib/search-expand.spec.ts
index b20964d..d0d0da9 100644
--- a/webui/src/lib/provider-expand.spec.ts
+++ b/webui/src/lib/search-expand.spec.ts
@@ -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']))
+ })
+})
diff --git a/webui/src/lib/search-expand.ts b/webui/src/lib/search-expand.ts
new file mode 100644
index 0000000..5872859
--- /dev/null
+++ b/webui/src/lib/search-expand.ts
@@ -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
+ manuallyCollapsed: Set
+}
+
+/**
+ * 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 {
+ 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()
+}
diff --git a/webui/src/lib/usage-search.spec.ts b/webui/src/lib/usage-search.spec.ts
new file mode 100644
index 0000000..a11af19
--- /dev/null
+++ b/webui/src/lib/usage-search.spec.ts
@@ -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)
+ })
+})
diff --git a/webui/src/lib/usage-search.ts b/webui/src/lib/usage-search.ts
new file mode 100644
index 0000000..65b4e8f
--- /dev/null
+++ b/webui/src/lib/usage-search.ts
@@ -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, 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, 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, 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,
+ 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, users: AdminUsageEntryView[], query: string): boolean {
+ return groupNameMatches(group, query) || matchingMembersOfGroup(group, users, query).length > 0
+}
diff --git a/webui/vite.config.ts b/webui/vite.config.ts
index 70b7acf..3d3bc89 100644
--- a/webui/vite.config.ts
+++ b/webui/vite.config.ts
@@ -33,11 +33,12 @@ export default defineConfig({
modulePreload: { polyfill: false },
},
test: {
- // Deliberately narrow: only lib/provider-expand.ts's pure state module
- // is under vitest right now (webui's test infra starts here, on
- // purpose — no snapshot/DOM/component testing this round). 'node' is
- // enough since the module touches no browser globals; jsdom is not
- // pulled in as a dependency for a module that doesn't need it.
+ // Deliberately narrow: only lib/'s pure modules (search-expand.ts,
+ // usage-search.ts) are under vitest right now (webui's test infra
+ // starts here, on purpose — no snapshot/DOM/component testing this
+ // round). 'node' is enough since neither module touches browser
+ // globals; jsdom is not pulled in as a dependency for modules that
+ // don't need it.
environment: 'node',
include: ['src/**/*.spec.ts'],
},