fix(webui): shared SearchInput/useSearchQuery, correct expand rationale

Address review of the user/group search filter (feat/user-search):

- Correct the "group name match does not auto-expand" rationale in
  UsageView.vue and lib/search-expand.ts. The prior comment claimed
  Overview has no provider-name search — false: OverviewView's
  providerMatches checks each model's full ROUTABLE id
  (`${provider}/${model}`, lib/format.ts's routableModelId), so a
  provider-name query does match and auto-expand there. The actual
  reason Usage withholds auto-expand from a name-only match: a group's
  name is already legible on its collapsed trigger row, so expanding
  every name match too would dump full member rosters for no reason;
  a member-only match has no such shortcut and still needs the reveal.
  Behavior is unchanged — only the (now accurate) justification.

- Extract components/SearchInput.vue and composables/useSearchQuery.ts.
  OverviewView's model search, UsageView's user/group search, and
  ChartsView's scope-picker search were three ~22-line copies of the
  same icon+Input+clear-button markup, plus three copies of the
  query/normalized/hasQuery/clear quartet. All three views now consume
  the shared component/composable. SearchInput's clear button is a real
  shadcn Button (variant="ghost", size="icon-xs" — 24x24px) instead of a
  hand-rolled <button>, fixing a WCAG 2.2 SC 2.5.8 hit-target/
  focus-visible gap once instead of three times.

- ChartsView's search input: w-48 (clipped the 22-char placeholder) to
  w-56, matching the sibling SelectTrigger's width.

- UsageView.vue's visibleMembers now calls the exported, tested
  matchingMembersOfGroup helper instead of re-implementing the same
  filter inline with userMatches.

- Reworded the "shared with ChartsView.vue" claims on membersOfGroup
  (usage-search.ts) and in UsageView.vue's visibleMembers doc comment:
  ChartsView never calls membersOfGroup directly, only indirectly via
  groupMatches -> matchingMembersOfGroup.

- UsageView's groups/users empty-state messages now follow Overview's
  aliasEmptyMessage three-way pattern ("none configured" vs "no
  groups/users match ..." vs "none"), distinguishing nothing-configured
  from nothing-matches instead of collapsing both to the same text.

Gate unchanged: npm run build + npm test + vue-tsc, make admin-ui
(separate regen commit), go test ./... -count=1, gofmt/vet, make
yaegi-check.
This commit is contained in:
2026-08-21 12:17:04 +01:00
parent 46537fa16a
commit 24809ed25a
7 changed files with 173 additions and 132 deletions
+7 -35
View File
@@ -1,9 +1,8 @@
<script setup lang="ts"> <script setup lang="ts">
import { faMagnifyingGlass, faXmark } from '@fortawesome/free-solid-svg-icons' import { computed, onMounted, onUnmounted, watch } from 'vue'
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
import SearchInput from '@/components/SearchInput.vue'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Input } from '@/components/ui/input'
import { import {
Select, Select,
SelectContent, SelectContent,
@@ -13,6 +12,7 @@ import {
} from '@/components/ui/select' } from '@/components/ui/select'
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs' import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs'
import UsageChart from '@/components/UsageChart.vue' import UsageChart from '@/components/UsageChart.vue'
import { useSearchQuery } from '@/composables/useSearchQuery'
import { groupMatches, userMatches } from '@/lib/usage-search' import { groupMatches, userMatches } from '@/lib/usage-search'
import { useDashboardStore } from '@/stores/dashboard' import { useDashboardStore } from '@/stores/dashboard'
import { type ChartTab, useHistoryStore, WINDOW_LABEL } from '@/stores/history' import { type ChartTab, useHistoryStore, WINDOW_LABEL } from '@/stores/history'
@@ -28,15 +28,9 @@ const history = useHistoryStore()
// CURRENTLY SELECTED scope is always kept in the list even if it stops // 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 // 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. // force-switch (or hide the label of) whatever chart is already rendered.
// Same matching helpers as UsageView.vue (lib/usage-search.ts) — one // Same shared SearchInput/useSearchQuery and lib/usage-search.ts matching
// implementation, no second copy. // helpers as UsageView.vue — one implementation, no second copy.
const scopeQuery = ref('') const { query: scopeQuery, normalized: normalizedScopeQuery, hasQuery: hasScopeQuery } = useSearchQuery()
const normalizedScopeQuery = computed(() => scopeQuery.value.trim().toLowerCase())
const hasScopeQuery = computed(() => normalizedScopeQuery.value.length > 0)
function clearScopeQuery(): void {
scopeQuery.value = ''
}
const scopeOptions = computed(() => { const scopeOptions = computed(() => {
const allUsers = dashboard.usage?.users ?? [] const allUsers = dashboard.usage?.users ?? []
@@ -85,29 +79,7 @@ watch(scopeOptions, (options) => {
<CardHeader class="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between"> <CardHeader class="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<CardTitle>Usage charts</CardTitle> <CardTitle>Usage charts</CardTitle>
<div class="flex flex-wrap items-center gap-3"> <div class="flex flex-wrap items-center gap-3">
<div class="relative w-48"> <SearchInput v-model="scopeQuery" placeholder="Filter users or groups" class="w-56" />
<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"> <Select :model-value="history.scope" @update:model-value="onScopeChange">
<SelectTrigger class="w-56"> <SelectTrigger class="w-56">
+5 -34
View File
@@ -3,14 +3,13 @@ import type { ColumnDef } from '@tanstack/vue-table'
import { import {
faCircleCheck, faCircleCheck,
faCircleXmark, faCircleXmark,
faMagnifyingGlass,
faTriangleExclamation, faTriangleExclamation,
faXmark,
} from '@fortawesome/free-solid-svg-icons' } from '@fortawesome/free-solid-svg-icons'
import { computed, h, reactive, ref, watch } from 'vue' import { computed, h, reactive, watch } from 'vue'
import DataTable from '@/components/DataTable.vue' import DataTable from '@/components/DataTable.vue'
import ModelChip from '@/components/ModelChip.vue' import ModelChip from '@/components/ModelChip.vue'
import SearchInput from '@/components/SearchInput.vue'
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from '@/components/ui/accordion' import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from '@/components/ui/accordion'
import { Badge } from '@/components/ui/badge' import { Badge } from '@/components/ui/badge'
import { import {
@@ -20,7 +19,7 @@ import {
CardHeader, CardHeader,
CardTitle, CardTitle,
} from '@/components/ui/card' } from '@/components/ui/card'
import { Input } from '@/components/ui/input' import { useSearchQuery } from '@/composables/useSearchQuery'
import { formatAgo, formatTimestamp, routableModelId } from '@/lib/format' import { formatAgo, formatTimestamp, routableModelId } from '@/lib/format'
import { type ExpandState, clearExpandOverrides, computeExpandedItems, toggleItemExpand } from '@/lib/search-expand' import { type ExpandState, clearExpandOverrides, computeExpandedItems, toggleItemExpand } from '@/lib/search-expand'
import { useDashboardStore } from '@/stores/dashboard' import { useDashboardStore } from '@/stores/dashboard'
@@ -56,9 +55,7 @@ const expandState: ExpandState = reactive({
}) })
// --- model/alias search filter (operator feature) --- // --- model/alias search filter (operator feature) ---
const modelQuery = ref('') const { query: modelQuery, normalized: normalizedQuery, hasQuery } = useSearchQuery()
const normalizedQuery = computed(() => modelQuery.value.trim().toLowerCase())
const hasQuery = computed(() => normalizedQuery.value.length > 0)
/** /**
* Matching is against each model's full ROUTABLE id * Matching is against each model's full ROUTABLE id
@@ -134,10 +131,6 @@ function visibleModels(p: AdminProviderView): string[] {
return hasQuery.value ? p.models.filter((m) => modelMatches(p.name, m)) : p.models return hasQuery.value ? p.models.filter((m) => modelMatches(p.name, m)) : p.models
} }
function clearQuery(): void {
modelQuery.value = ''
}
// --- model aliases (sortable DataTable, operator directive) --- // --- model aliases (sortable DataTable, operator directive) ---
// //
// The alias id gets the ModelChip copy treatment (an alias name IS the // The alias id gets the ModelChip copy treatment (an alias name IS the
@@ -229,29 +222,7 @@ const aliasEmptyMessage = computed(() =>
Every configured upstream and its last discovery refresh. Search filters providers and aliases by Every configured upstream and its last discovery refresh. Search filters providers and aliases by
model id. model id.
</CardDescription> </CardDescription>
<div class="relative mt-2 max-w-sm"> <SearchInput v-model="modelQuery" placeholder="Search models or aliases..." class="mt-2 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="modelQuery"
type="text"
placeholder="Search models or aliases..."
aria-label="Search models or aliases"
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>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<p v-if="!overview?.providers.length" class="py-6 text-center text-sm text-muted-foreground">none</p> <p v-if="!overview?.providers.length" class="py-6 text-center text-sm text-muted-foreground">none</p>
+78
View File
@@ -0,0 +1,78 @@
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { faMagnifyingGlass, faXmark } from '@fortawesome/free-solid-svg-icons'
import { computed } from 'vue'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { cn } from '@/lib/utils'
/**
* SearchInput is the one search-box control every filter in this panel
* uses — OverviewView's model search, UsageView's user/group search, and
* ChartsView's scope-picker search each grew an identical ~22-line
* magnifying-glass-icon + Input + clear-button block; extracted into one
* component (vue.md: "if you've written it twice, you owe an
* abstraction"). A controlled component (modelValue/update:modelValue),
* not v-model'd internally, since the incoming modelValue is a prop —
* callers pair it with `useSearchQuery()` (composables/useSearchQuery.ts)
* for the query/normalized/hasQuery/clear state, or v-model it against any
* plain string ref directly.
*
* The clear button is a real shadcn Button (variant="ghost", size
* "icon-xs") instead of a hand-rolled `<button>` — review fix: the
* hand-rolled version had no focus-visible ring of its own and a smaller
* than WCAG 2.2 SC 2.5.8 24x24 minimum hit target. Button's "icon-xs" size
* is exactly size-6 (24x24px, ui/button/index.ts), and buttonVariants
* already carries a focus-visible ring — fixing the a11y gap once here
* instead of at every call site.
*/
const props = defineProps<{
modelValue: string
placeholder: string
class?: HTMLAttributes['class']
}>()
const emit = defineEmits<{
(e: 'update:modelValue', value: string): void
}>()
const hasValue = computed(() => props.modelValue.length > 0)
function onInput(value: string | number): void {
emit('update:modelValue', String(value))
}
function onClear(): void {
emit('update:modelValue', '')
}
</script>
<template>
<div :class="cn('relative', props.class)">
<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
:model-value="modelValue"
type="text"
:placeholder="placeholder"
:aria-label="placeholder"
class="pr-8 pl-8"
@update:model-value="onInput"
/>
<Button
v-if="hasValue"
type="button"
variant="ghost"
size="icon-xs"
aria-label="Clear search"
class="absolute top-1/2 right-1 -translate-y-1/2 text-muted-foreground hover:text-foreground"
@click="onClear"
>
<FontAwesomeIcon :icon="faXmark" class="size-3.5" />
</Button>
</div>
</template>
+47 -56
View File
@@ -1,19 +1,19 @@
<script setup lang="ts"> <script setup lang="ts">
import type { SortingState } from '@tanstack/vue-table' 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 { getCoreRowModel, getSortedRowModel, useVueTable } from '@tanstack/vue-table'
import { computed, reactive, ref, watch } from 'vue' import { computed, reactive, ref, watch } from 'vue'
import SearchInput from '@/components/SearchInput.vue'
import SortHeaderButton from '@/components/SortHeaderButton.vue' import SortHeaderButton from '@/components/SortHeaderButton.vue'
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from '@/components/ui/accordion' import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from '@/components/ui/accordion'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card' import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Input } from '@/components/ui/input'
import { valueUpdater } from '@/components/ui/table' import { valueUpdater } from '@/components/ui/table'
import UsageTable from '@/components/UsageTable.vue' import UsageTable from '@/components/UsageTable.vue'
import { useSearchQuery } from '@/composables/useSearchQuery'
import { formatCost, formatLimits } from '@/lib/format' import { formatCost, formatLimits } from '@/lib/format'
import { type ExpandState, clearExpandOverrides, computeExpandedItems, toggleItemExpand } from '@/lib/search-expand' import { type ExpandState, clearExpandOverrides, computeExpandedItems, toggleItemExpand } from '@/lib/search-expand'
import { usageColumns } from '@/lib/usage-columns' import { usageColumns } from '@/lib/usage-columns'
import { groupMatches, groupNameMatches, membersOfGroup, userMatches } from '@/lib/usage-search' import { groupMatches, groupNameMatches, matchingMembersOfGroup, membersOfGroup, userMatches } from '@/lib/usage-search'
import { useDashboardStore } from '@/stores/dashboard' import { useDashboardStore } from '@/stores/dashboard'
import type { AdminUsageEntryView } from '@/types/api' import type { AdminUsageEntryView } from '@/types/api'
@@ -36,17 +36,12 @@ function memberCountOf(entry: AdminUsageEntryView): string {
} }
// --- user/group search filter (operator feature, mirrors // --- user/group search filter (operator feature, mirrors
// OverviewView.vue's model search: same Input component, styling, and // OverviewView.vue's model search via the shared SearchInput component and
// clear-button pattern; matching logic lives in lib/usage-search.ts so // useSearchQuery composable — same styling, same clear-button behavior.
// ChartsView.vue's own scope-picker filter reuses the identical helpers — // Matching logic lives in lib/usage-search.ts, which ChartsView.vue's own
// no second copy of "does this user/group match?") --- // scope-picker filter calls directly too (groupMatches, userMatches) — no
const userQuery = ref('') // second copy of "does this user/group match?") ---
const normalizedQuery = computed(() => userQuery.value.trim().toLowerCase()) const { query: userQuery, normalized: normalizedQuery, hasQuery } = useSearchQuery()
const hasQuery = computed(() => normalizedQuery.value.length > 0)
function clearQuery(): void {
userQuery.value = ''
}
/** /**
* filteredGroups is every group matching the query — own name OR a * filteredGroups is every group matching the query — own name OR a
@@ -67,14 +62,27 @@ const filteredGroups = computed<AdminUsageEntryView[]>(() => {
/** /**
* memberOnlyMatchIds is filteredGroups narrowed to groups that matched * memberOnlyMatchIds is filteredGroups narrowed to groups that matched
* ONLY via a member, never via their own name — the auto-expand target * ONLY via a member, never via their own name — the auto-expand target
* set (search-expand.ts's computeExpandedItems `matchingIds`). A group * set (search-expand.ts's computeExpandedItems `matchingIds`).
* whose OWN name matched the query is already visibly identified by its *
* collapsed trigger row, so forcing it open too would just be noisy; * This is a DELIBERATE divergence from OverviewView.vue, not an
* unlike OverviewView.vue, where a provider only ever matches via a * oversight, and NOT because Overview lacks a provider-name match —
* model (there is no provider-name search there — see that view's own * it doesn't lack one: providerMatches there (OverviewView.vue) checks
* CardDescription), so every match auto-expands there. search-expand.ts * each model's full ROUTABLE id, `${provider}/${model}`
* itself has no opinion on this; it is purely this view's own choice of * (lib/format.ts's routableModelId), so a query like "openai" matches
* matchingIds. * every "openai/..." model id and the whole provider auto-expands with
* every one of them showing. That is correct there — a provider's own
* model list IS what the search is for, so revealing it is the point,
* and EVERY provider match (name-flavored or not) auto-expands.
*
* Groups differ: a group's own name is already fully legible on its
* COLLAPSED trigger row (the `<AccordionTrigger>` below) — no expansion
* needed to read a name match. Auto-expanding every name-matched group too
* would dump every one of their full member rosters onto the screen for a
* query that only needed the trigger row to already be visible. A member-ONLY
* match has no such shortcut — the member that matched stays invisible
* until the group opens — so that case alone still auto-expands.
* search-expand.ts itself has no opinion on any of this; it is purely
* this view's own choice of matchingIds.
*/ */
const memberOnlyMatchIds = computed<string[]>(() => { const memberOnlyMatchIds = computed<string[]>(() => {
if (!hasQuery.value) return [] if (!hasQuery.value) return []
@@ -83,21 +91,23 @@ const memberOnlyMatchIds = computed<string[]>(() => {
/** /**
* visibleMembers implements the members-table distinction (binding * visibleMembers implements the members-table distinction (binding
* semantics): a group matched by its OWN name shows every member; a group * semantics): a group matched by its OWN name shows every member
* that matched only via a member shows only the matching ones. No active * (membersOfGroup); a group that matched only via a member shows only
* query behaves like a name match — show everyone, today's behavior * the matching ones — the exported, tested matchingMembersOfGroup
* unchanged. Replaces the former local membersOf helper — the join itself * helper (lib/usage-search.ts), not a re-filter of userMatches here. No
* now lives in lib/usage-search.ts's membersOfGroup, shared with * active query behaves like a name match — show everyone, today's
* ChartsView.vue. * behavior unchanged. Replaces the former local membersOf helper.
*/ */
function visibleMembers(group: AdminUsageEntryView): AdminUsageEntryView[] { function visibleMembers(group: AdminUsageEntryView): AdminUsageEntryView[] {
const users = usage.value?.users ?? [] const users = usage.value?.users ?? []
const all = membersOfGroup(group, users) if (!hasQuery.value || groupNameMatches(group, normalizedQuery.value)) return membersOfGroup(group, users)
if (!hasQuery.value || groupNameMatches(group, normalizedQuery.value)) return all return matchingMembersOfGroup(group, users, normalizedQuery.value)
return all.filter((u) => userMatches(u, normalizedQuery.value))
} }
const groupsEmptyMessage = computed(() => (hasQuery.value ? `no groups match "${userQuery.value}"` : 'none')) /** groupsEmptyMessage mirrors OverviewView.vue's aliasEmptyMessage three-way pattern: distinguishes nothing configured from nothing matching the active query, so an empty Groups card never reads the same regardless of why it's empty. */
const groupsEmptyMessage = computed(() =>
!usage.value?.groups.length ? 'none configured' : hasQuery.value ? `no groups match "${userQuery.value}"` : 'none',
)
/** /**
* filteredUsers is every user matching the query directly, PLUS every * filteredUsers is every user matching the query directly, PLUS every
@@ -119,7 +129,10 @@ const filteredUsers = computed<AdminUsageEntryView[]>(() => {
(u) => userMatches(u, normalizedQuery.value) || (u.groupName !== undefined && nameMatchedGroupIds.has(u.groupName)), (u) => userMatches(u, normalizedQuery.value) || (u.groupName !== undefined && nameMatchedGroupIds.has(u.groupName)),
) )
}) })
const usersEmptyMessage = computed(() => (hasQuery.value ? `no users match "${userQuery.value}"` : 'none')) /** usersEmptyMessage mirrors groupsEmptyMessage's three-way pattern (see its own doc comment). */
const usersEmptyMessage = computed(() =>
!usage.value?.users.length ? 'none configured' : hasQuery.value ? `no users match "${userQuery.value}"` : 'none',
)
// --- Groups accordion (operator directive) --- // --- Groups accordion (operator directive) ---
// //
@@ -216,29 +229,7 @@ watch(userQuery, (value) => {
<template> <template>
<div class="flex flex-col gap-6"> <div class="flex flex-col gap-6">
<div class="relative max-w-sm"> <SearchInput v-model="userQuery" placeholder="Filter users or groups" class="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"> <Card v-if="usage">
<CardHeader> <CardHeader>
+24
View File
@@ -0,0 +1,24 @@
import { computed, ref } from 'vue'
/**
* useSearchQuery is the one raw-query → {normalized, hasQuery, clear}
* adapter every search filter in this panel uses — OverviewView's model
* search, UsageView's user/group search, ChartsView's scope-picker search
* all had their own copy of this identical query/normalizedQuery/hasQuery/
* clearQuery quartet; extracted so it exists once (vue.md: "if you've
* written it twice, you owe an abstraction"). Callers still own their own
* matching logic entirely (OverviewView's modelMatches/providerMatches,
* lib/usage-search.ts's helpers) — this only normalizes the raw input the
* same way every call site already agreed on: trimmed, lowercased.
*/
export function useSearchQuery() {
const query = ref('')
const normalized = computed(() => query.value.trim().toLowerCase())
const hasQuery = computed(() => normalized.value.length > 0)
function clear(): void {
query.value = ''
}
return { query, normalized, hasQuery, clear }
}
+6 -4
View File
@@ -34,10 +34,12 @@ export interface ExpandState {
* manuallyExpanded plus every id in matchingIds that is not in * manuallyExpanded plus every id in matchingIds that is not in
* manuallyCollapsed (auto-expand every match, unless the user explicitly * manuallyCollapsed (auto-expand every match, unless the user explicitly
* closed that one). The caller decides what "matching" means for its own * closed that one). The caller decides what "matching" means for its own
* view — OverviewView.vue passes every provider with a matching model; * view, and this module has no opinion on it either way — OverviewView.vue
* UsageView.vue passes every group that matches ONLY via a member, not via * passes every provider that itself matched at all (every match there
* its own name (see that view's own doc comment for why a name match * implies at least one matching model, so every match auto-expands);
* alone does not force-expand there). * UsageView.vue instead passes only the subset of its own matches that
* matched via a member rather than via the group's own name (see that
* view's own doc comment for why it draws that narrower distinction).
*/ */
export function computeExpandedItems(state: ExpandState, hasQuery: boolean, matchingIds: string[]): Set<string> { export function computeExpandedItems(state: ExpandState, hasQuery: boolean, matchingIds: string[]): Set<string> {
if (!hasQuery) return new Set(state.manuallyExpanded) if (!hasQuery) return new Set(state.manuallyExpanded)
+6 -3
View File
@@ -28,9 +28,12 @@ export function groupNameMatches(group: Pick<AdminUsageEntryView, 'id'>, query:
* `groupName === group.id` — a user's row already carries its own group * `groupName === group.id` — a user's row already carries its own group
* name (admin.go's adminUsageEntryView.GroupName, set in buildAdminUsage), * name (admin.go's adminUsageEntryView.GroupName, set in buildAdminUsage),
* so this is a plain client-side join against the already-polled * so this is a plain client-side join against the already-polled
* GET /admin/api/usage response, no extra fetch. Shared so UsageView.vue * GET /admin/api/usage response, no extra fetch. UsageView.vue calls this
* and ChartsView.vue never re-derive this join differently (it replaces * directly (replacing its own former local membersOf helper);
* UsageView.vue's own former local membersOf helper). * matchingMembersOfGroup below builds on it too, which is how
* ChartsView.vue's groupMatches call ends up depending on this same join
* — indirectly, through matchingMembersOfGroup — without calling
* membersOfGroup itself.
*/ */
export function membersOfGroup(group: Pick<AdminUsageEntryView, 'id'>, users: AdminUsageEntryView[]): AdminUsageEntryView[] { export function membersOfGroup(group: Pick<AdminUsageEntryView, 'id'>, users: AdminUsageEntryView[]): AdminUsageEntryView[] {
return users.filter((u) => u.groupName === group.id) return users.filter((u) => u.groupName === group.id)