mirror of
https://github.com/lukaszraczylo/traefik-llmgateway.git
synced 2026-10-12 04:54:45 +00:00
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:
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user