mirror of
https://github.com/lukaszraczylo/traefik-llmgateway.git
synced 2026-10-17 05:37:43 +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:
@@ -1,7 +1,9 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, onUnmounted, watch } from 'vue'
|
||||
import { faMagnifyingGlass, faXmark } from '@fortawesome/free-solid-svg-icons'
|
||||
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
|
||||
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
@@ -11,6 +13,7 @@ import {
|
||||
} from '@/components/ui/select'
|
||||
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
||||
import UsageChart from '@/components/UsageChart.vue'
|
||||
import { groupMatches, userMatches } from '@/lib/usage-search'
|
||||
import { useDashboardStore } from '@/stores/dashboard'
|
||||
import { type ChartTab, useHistoryStore, WINDOW_LABEL } from '@/stores/history'
|
||||
import type { HistoryWindow } from '@/types/api'
|
||||
@@ -18,9 +21,35 @@ import type { HistoryWindow } from '@/types/api'
|
||||
const dashboard = useDashboardStore()
|
||||
const history = useHistoryStore()
|
||||
|
||||
// --- user/group search filter (operator feature) ---
|
||||
//
|
||||
// Filters the scope picker's user/group list only — "total" always stays
|
||||
// reachable (unconditionally the list's first entry, below), and the
|
||||
// CURRENTLY SELECTED scope is always kept in the list even if it stops
|
||||
// matching a new query: the filter narrows what's OFFERED, it must never
|
||||
// force-switch (or hide the label of) whatever chart is already rendered.
|
||||
// Same matching helpers as UsageView.vue (lib/usage-search.ts) — one
|
||||
// implementation, no second copy.
|
||||
const scopeQuery = ref('')
|
||||
const normalizedScopeQuery = computed(() => scopeQuery.value.trim().toLowerCase())
|
||||
const hasScopeQuery = computed(() => normalizedScopeQuery.value.length > 0)
|
||||
|
||||
function clearScopeQuery(): void {
|
||||
scopeQuery.value = ''
|
||||
}
|
||||
|
||||
const scopeOptions = computed(() => {
|
||||
const users = (dashboard.usage?.users ?? []).map((u) => ({ value: `user:${u.id}`, label: u.id }))
|
||||
const groups = (dashboard.usage?.groups ?? []).map((g) => ({ value: `group:${g.id}`, label: g.id }))
|
||||
const allUsers = dashboard.usage?.users ?? []
|
||||
const allGroups = dashboard.usage?.groups ?? []
|
||||
const users = allUsers
|
||||
.filter((u) => !hasScopeQuery.value || userMatches(u, normalizedScopeQuery.value) || `user:${u.id}` === history.scope)
|
||||
.map((u) => ({ value: `user:${u.id}`, label: u.id }))
|
||||
const groups = allGroups
|
||||
.filter(
|
||||
(g) =>
|
||||
!hasScopeQuery.value || groupMatches(g, allUsers, normalizedScopeQuery.value) || `group:${g.id}` === history.scope,
|
||||
)
|
||||
.map((g) => ({ value: `group:${g.id}`, label: g.id }))
|
||||
return [{ value: 'total', label: 'Total (all traffic)' }, ...groups, ...users]
|
||||
})
|
||||
|
||||
@@ -56,6 +85,30 @@ watch(scopeOptions, (options) => {
|
||||
<CardHeader class="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
<CardTitle>Usage charts</CardTitle>
|
||||
<div class="flex flex-wrap items-center gap-3">
|
||||
<div class="relative w-48">
|
||||
<FontAwesomeIcon
|
||||
:icon="faMagnifyingGlass"
|
||||
class="pointer-events-none absolute top-1/2 left-2.5 size-3.5 -translate-y-1/2 text-muted-foreground"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<Input
|
||||
v-model="scopeQuery"
|
||||
type="text"
|
||||
placeholder="Filter users or groups"
|
||||
aria-label="Filter users or groups"
|
||||
class="pr-8 pl-8"
|
||||
/>
|
||||
<button
|
||||
v-if="hasScopeQuery"
|
||||
type="button"
|
||||
aria-label="Clear search"
|
||||
class="absolute top-1/2 right-2 -translate-y-1/2 rounded text-muted-foreground hover:text-foreground"
|
||||
@click="clearScopeQuery"
|
||||
>
|
||||
<FontAwesomeIcon :icon="faXmark" class="size-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<Select :model-value="history.scope" @update:model-value="onScopeChange">
|
||||
<SelectTrigger class="w-56">
|
||||
<SelectValue placeholder="Scope" />
|
||||
|
||||
@@ -22,7 +22,7 @@ import {
|
||||
} from '@/components/ui/card'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { formatAgo, formatTimestamp, routableModelId } from '@/lib/format'
|
||||
import { type ExpandState, clearExpandOverrides, computeExpandedProviders, toggleProviderExpand } from '@/lib/provider-expand'
|
||||
import { type ExpandState, clearExpandOverrides, computeExpandedItems, toggleItemExpand } from '@/lib/search-expand'
|
||||
import { useDashboardStore } from '@/stores/dashboard'
|
||||
import type { AdminAliasView, AdminProviderView } from '@/types/api'
|
||||
|
||||
@@ -37,18 +37,19 @@ const overview = computed(() => dashboard.overview)
|
||||
// trigger is one provider's summary line; the content holds its detail
|
||||
// (base URL, last refresh, last error) plus its model-id chips.
|
||||
//
|
||||
// Expand STATE itself is unchanged: lib/provider-expand.ts's plain,
|
||||
// Vue-free two-set module (manuallyExpanded/manuallyCollapsed) — the
|
||||
// exact search-interaction bug fix a prior review flagged, covered by a
|
||||
// real vitest spec (lib/provider-expand.spec.ts) that exercises the
|
||||
// module directly, without mounting Vue. Only the template-facing
|
||||
// adapter changed: expandedProviderValues below is a
|
||||
// Expand STATE itself is unchanged: lib/search-expand.ts's plain,
|
||||
// Vue-free two-set module (manuallyExpanded/manuallyCollapsed, generalized
|
||||
// from provider-expand.ts so UsageView.vue's Groups accordion shares the
|
||||
// same tested logic) — the exact search-interaction bug fix a prior review
|
||||
// flagged, covered by a real vitest spec (lib/search-expand.spec.ts) that
|
||||
// exercises the module directly, without mounting Vue. Only the
|
||||
// template-facing adapter changed: expandedProviderValues below is a
|
||||
// writable computed translating that Set-based state into the string[]
|
||||
// shape Accordion's `type="multiple"` v-model expects, and back —
|
||||
// clicking a trigger fires the setter with the new array; diffing it
|
||||
// against the previous effective set finds the one name that changed
|
||||
// and replays it through the SAME tested toggleProviderExpand used
|
||||
// before, so Accordion never owns this state itself, only displays it.
|
||||
// and replays it through the SAME tested toggleItemExpand used before, so
|
||||
// Accordion never owns this state itself, only displays it.
|
||||
const expandState: ExpandState = reactive({
|
||||
manuallyExpanded: new Set<string>(),
|
||||
manuallyCollapsed: new Set<string>(),
|
||||
@@ -86,18 +87,18 @@ const filteredAliases = computed<AdminAliasView[]>(() => {
|
||||
return hasQuery.value ? all.filter(aliasMatches) : all
|
||||
})
|
||||
|
||||
/** expandedProviders is the EFFECTIVE (possibly auto-expanded-by-search) set — see lib/provider-expand.ts's own doc comments for the manual/auto-expand/override semantics. */
|
||||
/** expandedProviders is the EFFECTIVE (possibly auto-expanded-by-search) set — see lib/search-expand.ts's own doc comments for the manual/auto-expand/override semantics. */
|
||||
const expandedProviders = computed<Set<string>>(() =>
|
||||
computeExpandedProviders(
|
||||
computeExpandedItems(
|
||||
expandState,
|
||||
hasQuery.value,
|
||||
filteredProviders.value.map((p) => p.name),
|
||||
),
|
||||
)
|
||||
|
||||
/** toggleProvider reads the CURRENT effective (visible) state for name before flipping it — see toggleProviderExpand's own doc comment for exactly which bug this avoids. */
|
||||
/** toggleProvider reads the CURRENT effective (visible) state for name before flipping it — see toggleItemExpand's own doc comment for exactly which bug this avoids. */
|
||||
function toggleProvider(name: string): void {
|
||||
toggleProviderExpand(expandState, name, expandedProviders.value.has(name))
|
||||
toggleItemExpand(expandState, name, expandedProviders.value.has(name))
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -19,6 +19,8 @@ const props = defineProps<{
|
||||
entries: AdminUsageEntryView[]
|
||||
secondaryColumnLabel: string
|
||||
secondaryValue: (entry: AdminUsageEntryView) => string
|
||||
/** Shown in the empty-state row when entries is empty — e.g. a filtered-search "no X match ..." message. Falls through to DataTable's own 'none' default when omitted. */
|
||||
emptyMessage?: string
|
||||
}>()
|
||||
|
||||
const columns = computed(() => usageColumns(props.idLabel, props.secondaryColumnLabel, props.secondaryValue))
|
||||
@@ -28,6 +30,7 @@ const columns = computed(() => usageColumns(props.idLabel, props.secondaryColumn
|
||||
<DataTable
|
||||
:columns="columns"
|
||||
:data="entries"
|
||||
:empty-message="emptyMessage"
|
||||
:row-class="(entry) => (entry.storeDown ? 'bg-destructive/10' : undefined)"
|
||||
/>
|
||||
</template>
|
||||
|
||||
@@ -1,15 +1,19 @@
|
||||
<script setup lang="ts">
|
||||
import type { SortingState } from '@tanstack/vue-table'
|
||||
import { faMagnifyingGlass, faXmark } from '@fortawesome/free-solid-svg-icons'
|
||||
import { getCoreRowModel, getSortedRowModel, useVueTable } from '@tanstack/vue-table'
|
||||
import { computed, ref } from 'vue'
|
||||
import { computed, reactive, ref, watch } from 'vue'
|
||||
|
||||
import SortHeaderButton from '@/components/SortHeaderButton.vue'
|
||||
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from '@/components/ui/accordion'
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { valueUpdater } from '@/components/ui/table'
|
||||
import UsageTable from '@/components/UsageTable.vue'
|
||||
import { formatCost, formatLimits } from '@/lib/format'
|
||||
import { type ExpandState, clearExpandOverrides, computeExpandedItems, toggleItemExpand } from '@/lib/search-expand'
|
||||
import { usageColumns } from '@/lib/usage-columns'
|
||||
import { groupMatches, groupNameMatches, membersOfGroup, userMatches } from '@/lib/usage-search'
|
||||
import { useDashboardStore } from '@/stores/dashboard'
|
||||
import type { AdminUsageEntryView } from '@/types/api'
|
||||
|
||||
@@ -31,20 +35,92 @@ function memberCountOf(entry: AdminUsageEntryView): string {
|
||||
return count === undefined ? '' : String(count)
|
||||
}
|
||||
|
||||
/**
|
||||
* membersOf returns one group's own member users' usage rows — 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 SAME GET /admin/api/usage response
|
||||
* already in the store, no extra fetch. Verified this field already
|
||||
* exists and is already asserted by a backend test
|
||||
* (admin_test.go's TestAdminUsage_MathAgainstSeededCounters checks
|
||||
* alice.GroupName == "agroup") — no backend change was needed for this.
|
||||
*/
|
||||
function membersOf(group: AdminUsageEntryView): AdminUsageEntryView[] {
|
||||
return (usage.value?.users ?? []).filter((u) => u.groupName === group.id)
|
||||
// --- user/group search filter (operator feature, mirrors
|
||||
// OverviewView.vue's model search: same Input component, styling, and
|
||||
// clear-button pattern; matching logic lives in lib/usage-search.ts so
|
||||
// ChartsView.vue's own scope-picker filter reuses the identical helpers —
|
||||
// no second copy of "does this user/group match?") ---
|
||||
const userQuery = ref('')
|
||||
const normalizedQuery = computed(() => userQuery.value.trim().toLowerCase())
|
||||
const hasQuery = computed(() => normalizedQuery.value.length > 0)
|
||||
|
||||
function clearQuery(): void {
|
||||
userQuery.value = ''
|
||||
}
|
||||
|
||||
/**
|
||||
* filteredGroups is every group matching the query — own name OR a
|
||||
* matching member (lib/usage-search.ts's groupMatches, binding semantics)
|
||||
* — or every group when there is no query. This feeds groupsTable's
|
||||
* `data()` below directly, so filtering happens upstream of sorting: the
|
||||
* Groups sort toolbar and the Accordion both operate on the same
|
||||
* (possibly narrowed) list, no separate filtering pass downstream of the
|
||||
* table.
|
||||
*/
|
||||
const filteredGroups = computed<AdminUsageEntryView[]>(() => {
|
||||
const all = usage.value?.groups ?? []
|
||||
if (!hasQuery.value) return all
|
||||
const users = usage.value?.users ?? []
|
||||
return all.filter((g) => groupMatches(g, users, normalizedQuery.value))
|
||||
})
|
||||
|
||||
/**
|
||||
* memberOnlyMatchIds is filteredGroups narrowed to groups that matched
|
||||
* ONLY via a member, never via their own name — the auto-expand target
|
||||
* set (search-expand.ts's computeExpandedItems `matchingIds`). A group
|
||||
* whose OWN name matched the query is already visibly identified by its
|
||||
* collapsed trigger row, so forcing it open too would just be noisy;
|
||||
* unlike OverviewView.vue, where a provider only ever matches via a
|
||||
* model (there is no provider-name search there — see that view's own
|
||||
* CardDescription), so every match auto-expands there. search-expand.ts
|
||||
* itself has no opinion on this; it is purely this view's own choice of
|
||||
* matchingIds.
|
||||
*/
|
||||
const memberOnlyMatchIds = computed<string[]>(() => {
|
||||
if (!hasQuery.value) return []
|
||||
return filteredGroups.value.filter((g) => !groupNameMatches(g, normalizedQuery.value)).map((g) => g.id)
|
||||
})
|
||||
|
||||
/**
|
||||
* visibleMembers implements the members-table distinction (binding
|
||||
* semantics): a group matched by its OWN name shows every member; a group
|
||||
* that matched only via a member shows only the matching ones. No active
|
||||
* query behaves like a name match — show everyone, today's behavior
|
||||
* unchanged. Replaces the former local membersOf helper — the join itself
|
||||
* now lives in lib/usage-search.ts's membersOfGroup, shared with
|
||||
* ChartsView.vue.
|
||||
*/
|
||||
function visibleMembers(group: AdminUsageEntryView): AdminUsageEntryView[] {
|
||||
const users = usage.value?.users ?? []
|
||||
const all = membersOfGroup(group, users)
|
||||
if (!hasQuery.value || groupNameMatches(group, normalizedQuery.value)) return all
|
||||
return all.filter((u) => userMatches(u, normalizedQuery.value))
|
||||
}
|
||||
|
||||
const groupsEmptyMessage = computed(() => (hasQuery.value ? `no groups match "${userQuery.value}"` : 'none'))
|
||||
|
||||
/**
|
||||
* filteredUsers is every user matching the query directly, PLUS every
|
||||
* member of a group that matched by NAME (semantics item 5: "a
|
||||
* group-name match includes all of that group's member rows"). A group
|
||||
* that matched only via ONE member must not pull its other, non-matching
|
||||
* members into this flat table too — that narrower distinction is
|
||||
* visibleMembers' job, scoped to the nested table inside an expanded
|
||||
* group, not this one.
|
||||
*/
|
||||
const filteredUsers = computed<AdminUsageEntryView[]>(() => {
|
||||
const all = usage.value?.users ?? []
|
||||
if (!hasQuery.value) return all
|
||||
const groups = usage.value?.groups ?? []
|
||||
const nameMatchedGroupIds = new Set(
|
||||
groups.filter((g) => groupNameMatches(g, normalizedQuery.value)).map((g) => g.id),
|
||||
)
|
||||
return all.filter(
|
||||
(u) => userMatches(u, normalizedQuery.value) || (u.groupName !== undefined && nameMatchedGroupIds.has(u.groupName)),
|
||||
)
|
||||
})
|
||||
const usersEmptyMessage = computed(() => (hasQuery.value ? `no users match "${userQuery.value}"` : 'none'))
|
||||
|
||||
// --- Groups accordion (operator directive) ---
|
||||
//
|
||||
// Groups render as a shadcn-vue Accordion, same component and rationale
|
||||
@@ -67,7 +143,7 @@ const groupColumns = usageColumns('Name', 'Members', memberCountOf)
|
||||
const groupSorting = ref<SortingState>([])
|
||||
const groupsTable = useVueTable({
|
||||
get data() {
|
||||
return usage.value?.groups ?? []
|
||||
return filteredGroups.value
|
||||
},
|
||||
get columns() {
|
||||
return groupColumns
|
||||
@@ -97,12 +173,73 @@ const toolbarHeaders = computed(() =>
|
||||
groupsTable.getHeaderGroups()[0].headers.filter((header) => TOOLBAR_COLUMN_IDS.has(header.column.id)),
|
||||
)
|
||||
|
||||
/** Which groups are open — plain array state (no search-driven auto-expand exists for this view, unlike Providers, so the two-set provider-expand.ts module is not needed here). */
|
||||
const expandedGroups = ref<string[]>([])
|
||||
/**
|
||||
* Which groups are open — driven by the SAME tested lib/search-expand.ts
|
||||
* state OverviewView.vue uses for providers (generalized from
|
||||
* provider-expand.ts specifically so this view could reuse it, rather
|
||||
* than fork a second copy — see that module's own doc comment). The
|
||||
* adapter shape is the identical get/set computed pattern
|
||||
* expandedProviderValues uses there, translating the Set-based effective
|
||||
* state into the string[] Accordion's `type="multiple"` v-model expects.
|
||||
*/
|
||||
const groupExpandState: ExpandState = reactive({
|
||||
manuallyExpanded: new Set<string>(),
|
||||
manuallyCollapsed: new Set<string>(),
|
||||
})
|
||||
const expandedGroups = computed<Set<string>>(() =>
|
||||
computeExpandedItems(groupExpandState, hasQuery.value, memberOnlyMatchIds.value),
|
||||
)
|
||||
function toggleGroup(id: string): void {
|
||||
toggleItemExpand(groupExpandState, id, expandedGroups.value.has(id))
|
||||
}
|
||||
const expandedGroupValues = computed<string[]>({
|
||||
get: () => Array.from(expandedGroups.value),
|
||||
set: (newValues) => {
|
||||
const next = new Set(newValues)
|
||||
for (const id of expandedGroups.value) {
|
||||
if (!next.has(id)) toggleGroup(id)
|
||||
}
|
||||
for (const id of next) {
|
||||
if (!expandedGroups.value.has(id)) toggleGroup(id)
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
// A stale manuallyCollapsed suppression from one search must never
|
||||
// silently carry into a later, unrelated one — see ExpandState's own doc
|
||||
// comment. Watching userQuery (not just the clear button) covers
|
||||
// backspacing to empty too — same convention as OverviewView.vue.
|
||||
watch(userQuery, (value) => {
|
||||
if (value.trim() === '') clearExpandOverrides(groupExpandState)
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col gap-6">
|
||||
<div class="relative max-w-sm">
|
||||
<FontAwesomeIcon
|
||||
:icon="faMagnifyingGlass"
|
||||
class="pointer-events-none absolute top-1/2 left-2.5 size-3.5 -translate-y-1/2 text-muted-foreground"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<Input
|
||||
v-model="userQuery"
|
||||
type="text"
|
||||
placeholder="Filter users or groups"
|
||||
aria-label="Filter users or groups"
|
||||
class="pr-8 pl-8"
|
||||
/>
|
||||
<button
|
||||
v-if="hasQuery"
|
||||
type="button"
|
||||
aria-label="Clear search"
|
||||
class="absolute top-1/2 right-2 -translate-y-1/2 rounded text-muted-foreground hover:text-foreground"
|
||||
@click="clearQuery"
|
||||
>
|
||||
<FontAwesomeIcon :icon="faXmark" class="size-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<Card v-if="usage">
|
||||
<CardHeader>
|
||||
<CardTitle>Total</CardTitle>
|
||||
@@ -141,7 +278,7 @@ const expandedGroups = ref<string[]>([])
|
||||
<SortHeaderButton v-for="header in toolbarHeaders" :key="header.id" :header="header" />
|
||||
</div>
|
||||
|
||||
<Accordion v-if="sortedGroupRows.length" v-model="expandedGroups" type="multiple" class="rounded-md border px-3">
|
||||
<Accordion v-if="sortedGroupRows.length" v-model="expandedGroupValues" type="multiple" class="rounded-md border px-3">
|
||||
<AccordionItem v-for="row in sortedGroupRows" :key="row.original.id" :value="row.original.id">
|
||||
<AccordionTrigger>
|
||||
<span class="flex flex-1 flex-wrap items-center gap-x-4 gap-y-1 pr-2 text-left">
|
||||
@@ -190,13 +327,13 @@ const expandedGroups = ref<string[]>([])
|
||||
<UsageTable
|
||||
id-label="Name"
|
||||
secondary-column-label="Group"
|
||||
:entries="membersOf(row.original)"
|
||||
:entries="visibleMembers(row.original)"
|
||||
:secondary-value="groupNameOf"
|
||||
/>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
</Accordion>
|
||||
<p v-else class="py-6 text-center text-sm text-muted-foreground">none</p>
|
||||
<p v-else class="py-6 text-center text-sm text-muted-foreground">{{ groupsEmptyMessage }}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@@ -208,8 +345,9 @@ const expandedGroups = ref<string[]>([])
|
||||
<UsageTable
|
||||
id-label="Name"
|
||||
secondary-column-label="Group"
|
||||
:entries="usage?.users ?? []"
|
||||
:entries="filteredUsers"
|
||||
:secondary-value="groupNameOf"
|
||||
:empty-message="usersEmptyMessage"
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@@ -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']))
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user