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">
import { faMagnifyingGlass, faXmark } from '@fortawesome/free-solid-svg-icons'
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
import { computed, onMounted, onUnmounted, watch } from 'vue'
import SearchInput from '@/components/SearchInput.vue'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Input } from '@/components/ui/input'
import {
Select,
SelectContent,
@@ -13,6 +12,7 @@ import {
} from '@/components/ui/select'
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs'
import UsageChart from '@/components/UsageChart.vue'
import { useSearchQuery } from '@/composables/useSearchQuery'
import { groupMatches, userMatches } from '@/lib/usage-search'
import { useDashboardStore } from '@/stores/dashboard'
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
// 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 = ''
}
// Same shared SearchInput/useSearchQuery and lib/usage-search.ts matching
// helpers as UsageView.vue — one implementation, no second copy.
const { query: scopeQuery, normalized: normalizedScopeQuery, hasQuery: hasScopeQuery } = useSearchQuery()
const scopeOptions = computed(() => {
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">
<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>
<SearchInput v-model="scopeQuery" placeholder="Filter users or groups" class="w-56" />
<Select :model-value="history.scope" @update:model-value="onScopeChange">
<SelectTrigger class="w-56">
+5 -34
View File
@@ -3,14 +3,13 @@ import type { ColumnDef } from '@tanstack/vue-table'
import {
faCircleCheck,
faCircleXmark,
faMagnifyingGlass,
faTriangleExclamation,
faXmark,
} 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 ModelChip from '@/components/ModelChip.vue'
import SearchInput from '@/components/SearchInput.vue'
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from '@/components/ui/accordion'
import { Badge } from '@/components/ui/badge'
import {
@@ -20,7 +19,7 @@ import {
CardHeader,
CardTitle,
} from '@/components/ui/card'
import { Input } from '@/components/ui/input'
import { useSearchQuery } from '@/composables/useSearchQuery'
import { formatAgo, formatTimestamp, routableModelId } from '@/lib/format'
import { type ExpandState, clearExpandOverrides, computeExpandedItems, toggleItemExpand } from '@/lib/search-expand'
import { useDashboardStore } from '@/stores/dashboard'
@@ -56,9 +55,7 @@ const expandState: ExpandState = reactive({
})
// --- model/alias search filter (operator feature) ---
const modelQuery = ref('')
const normalizedQuery = computed(() => modelQuery.value.trim().toLowerCase())
const hasQuery = computed(() => normalizedQuery.value.length > 0)
const { query: modelQuery, normalized: normalizedQuery, hasQuery } = useSearchQuery()
/**
* 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
}
function clearQuery(): void {
modelQuery.value = ''
}
// --- model aliases (sortable DataTable, operator directive) ---
//
// 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
model id.
</CardDescription>
<div class="relative 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>
<SearchInput v-model="modelQuery" placeholder="Search models or aliases..." class="mt-2 max-w-sm" />
</CardHeader>
<CardContent>
<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">
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, reactive, ref, watch } from 'vue'
import SearchInput from '@/components/SearchInput.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 { useSearchQuery } from '@/composables/useSearchQuery'
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 { groupMatches, groupNameMatches, matchingMembersOfGroup, membersOfGroup, userMatches } from '@/lib/usage-search'
import { useDashboardStore } from '@/stores/dashboard'
import type { AdminUsageEntryView } from '@/types/api'
@@ -36,17 +36,12 @@ function memberCountOf(entry: AdminUsageEntryView): string {
}
// --- 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 = ''
}
// OverviewView.vue's model search via the shared SearchInput component and
// useSearchQuery composable — same styling, same clear-button behavior.
// Matching logic lives in lib/usage-search.ts, which ChartsView.vue's own
// scope-picker filter calls directly too (groupMatches, userMatches) — no
// second copy of "does this user/group match?") ---
const { query: userQuery, normalized: normalizedQuery, hasQuery } = useSearchQuery()
/**
* 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
* 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.
* set (search-expand.ts's computeExpandedItems `matchingIds`).
*
* This is a DELIBERATE divergence from OverviewView.vue, not an
* oversight, and NOT because Overview lacks a provider-name match —
* it doesn't lack one: providerMatches there (OverviewView.vue) checks
* each model's full ROUTABLE id, `${provider}/${model}`
* (lib/format.ts's routableModelId), so a query like "openai" matches
* 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[]>(() => {
if (!hasQuery.value) return []
@@ -83,21 +91,23 @@ const memberOnlyMatchIds = computed<string[]>(() => {
/**
* 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.
* semantics): a group matched by its OWN name shows every member
* (membersOfGroup); a group that matched only via a member shows only
* the matching ones — the exported, tested matchingMembersOfGroup
* helper (lib/usage-search.ts), not a re-filter of userMatches here. No
* active query behaves like a name match — show everyone, today's
* behavior unchanged. Replaces the former local membersOf helper.
*/
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))
if (!hasQuery.value || groupNameMatches(group, normalizedQuery.value)) return membersOfGroup(group, users)
return matchingMembersOfGroup(group, users, 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
@@ -119,7 +129,10 @@ const filteredUsers = computed<AdminUsageEntryView[]>(() => {
(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) ---
//
@@ -216,29 +229,7 @@ watch(userQuery, (value) => {
<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>
<SearchInput v-model="userQuery" placeholder="Filter users or groups" class="max-w-sm" />
<Card v-if="usage">
<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
* 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).
* view, and this module has no opinion on it either way — OverviewView.vue
* passes every provider that itself matched at all (every match there
* implies at least one matching model, so every match auto-expands);
* 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> {
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
* 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).
* GET /admin/api/usage response, no extra fetch. UsageView.vue calls this
* directly (replacing its 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[] {
return users.filter((u) => u.groupName === group.id)