feat(webui): provider model accordion

Clicking a provider row in the dashboard expands to show its model
list, with a search filter and copy-to-clipboard on each model.

Backend (registry.go/admin.go): providerSnapshot gains models []string
- the sorted explicit union discovered id set, computed in the SAME
providerState.mu critical section as lastRefresh/lastErr (not via a
separate knownIDs() call, which takes the same non-reentrant lock and
would deadlock) so the three values are never assembled from two
separate locked passes a concurrent finishRefresh could interleave
between. adminProviderView exposes it as models (never nil - a real,
if empty, JSON array). New tests: overview shape asserts sorted
models per provider; a new TestModelRegistry_Snapshot_ModelsStaleWhileError
proves a failed refresh after an earlier success keeps the previous
discovered set (existing stale-while-error semantics, no special
handling added); TestAdmin_SecretRedaction unaffected (model ids are
not secrets, still passes as-is).

Frontend: the accordion is NOT shadcn-vue's Accordion or Collapsible -
both wrap trigger+content in one div, which cannot legally sit
between two tr elements inside a table/tbody. Installed
Collapsible via the CLI to check (verified components.json's now-
defused font/iconLibrary omission holds under a real install: main.css
stayed byte-for-byte unchanged) and inspected its source: reka-ui's
CollapsibleContent does support an "as" prop that could force it onto
a tr, but its animation-measurement code (getBoundingClientRect +
inline transition styles) is untested in that shape and not worth
taking on for an instant show/hide with no transition - removed the
installed files, kept a plain reactive Set<string> + v-if second
TableRow, which reuses Accordion's own visual language (rotating
chevron, keyboard-accessible trigger row: role=button, tabindex,
Enter/Space handlers) without misusing a component built for block
content.

Model chips (new ModelChip.vue) show the full ROUTABLE id -
"{providerName}/{modelId}" unconditionally, so a discovered id that
already contains a slash (uni/deepseek-v4-flash-0731 on provider
"real") renders as real/uni/deepseek-v4-flash-0731 and stays
copy-paste-valid - and copy that id to the clipboard on click
(lib/clipboard.ts: navigator.clipboard.writeText with a
select-the-text fallback when the Clipboard API is unavailable, never
the deprecated execCommand('copy')), with a brief checkmark
copied-state affordance. No CSP change: Clipboard access is a browser
permission, not a CSP directive.

Search input (OverviewView.vue) filters the providers table to
providers with a matching model (case-insensitive substring) and the
aliases table to aliases whose own name or target matches, auto-
expanding matching providers while a query is active and filtering
each expanded provider's own chip list down to just its matches (so a
72-model provider does not stay a 72-chip wall for a 1-model search).
Manual expand/collapse state is tracked separately from the search's
own auto-expand set, so clearing the query restores exactly what the
user had open by hand - nothing to explicitly "restore", since the
auto-expand set was never written into the manual one.

Integration: TestAdminDashboard's overview assertion extended
(adminProviderModels helper) to check the openai provider's models
array contains dynamic.yml.tmpl's explicit "gpt-mock" model - stays
API-level (asserts the JSON payload), not the rendered HTML, so the
prefixed-display-form request is left at the frontend only per scope.

Regenerated via make admin-ui; admin_assets_gen.go included.
This commit is contained in:
2026-08-21 10:18:57 +01:00
parent 1aa2b43932
commit f9b289f1ea
11 changed files with 4278 additions and 3763 deletions
+44
View File
@@ -0,0 +1,44 @@
<script setup lang="ts">
import { faCheck, faCopy } from '@fortawesome/free-solid-svg-icons'
import { ref, useTemplateRef } from 'vue'
import { Badge } from '@/components/ui/badge'
import { copyText } from '@/lib/clipboard'
/**
* ModelChip is one clickable, copyable model-id chip in a provider's
* expanded model list (OverviewView.vue's provider-model accordion). id
* is the full routable id (lib/format.ts's routableModelId) — what
* actually gets copied, so a pasted value works straight into a
* `model: "..."` request field.
*/
const props = defineProps<{ id: string }>()
const labelRef = useTemplateRef<HTMLElement>('label')
const state = ref<'idle' | 'copied' | 'selected'>('idle')
let resetTimer: ReturnType<typeof setTimeout> | undefined
async function onClick(): Promise<void> {
const result = await copyText(props.id, labelRef.value ?? undefined)
if (result === 'failed') return
state.value = result
clearTimeout(resetTimer)
resetTimer = setTimeout(() => {
state.value = 'idle'
}, 1500)
}
</script>
<template>
<Badge
as="button"
type="button"
variant="secondary"
class="cursor-pointer gap-1 font-mono text-xs font-normal transition-colors hover:bg-accent hover:text-accent-foreground"
:title="state === 'idle' ? `Copy ${id}` : state === 'copied' ? 'Copied' : 'Selected — press Ctrl/Cmd+C'"
@click="onClick"
>
<span ref="label">{{ id }}</span>
<FontAwesomeIcon :icon="state === 'idle' ? faCopy : faCheck" class="size-2.5 shrink-0" aria-hidden="true" />
</Badge>
</template>
+169 -13
View File
@@ -1,7 +1,15 @@
<script setup lang="ts">
import { faCircleCheck, faCircleXmark, faTriangleExclamation } from '@fortawesome/free-solid-svg-icons'
import { computed } from 'vue'
import {
faChevronRight,
faCircleCheck,
faCircleXmark,
faMagnifyingGlass,
faTriangleExclamation,
faXmark,
} from '@fortawesome/free-solid-svg-icons'
import { computed, reactive, ref } from 'vue'
import ModelChip from '@/components/ModelChip.vue'
import {
Card,
CardContent,
@@ -9,6 +17,7 @@ import {
CardHeader,
CardTitle,
} from '@/components/ui/card'
import { Input } from '@/components/ui/input'
import {
Table,
TableBody,
@@ -18,11 +27,89 @@ import {
TableHeader,
TableRow,
} from '@/components/ui/table'
import { formatAgo, formatTimestamp } from '@/lib/format'
import { formatAgo, formatTimestamp, routableModelId } from '@/lib/format'
import { useDashboardStore } from '@/stores/dashboard'
import type { AdminAliasView, AdminProviderView } from '@/types/api'
const dashboard = useDashboardStore()
const overview = computed(() => dashboard.overview)
// --- provider row expand/collapse ---
//
// Providers table rows expand to a model list (operator feature). This is
// NOT shadcn-vue's Accordion or Collapsible component: both wrap
// trigger+content in one <div>, which cannot legally sit between two
// <tr> elements inside a <table>/<tbody> — a browser's HTML table parser
// reparents (hoists) a <div> found there out of the table, breaking the
// layout. (reka-ui's Collapsible primitives DO support an `as` prop that
// could in principle render Root as a <tbody> and Trigger/Content as
// <tr> — installed and inspected via the CLI to check, then not used:
// CollapsibleContent's animation-measurement code calls
// getBoundingClientRect() and sets inline transition/animation styles on
// whatever element `as` names, an interaction with a <tr> this component
// was never designed around and was not worth taking on for an
// instant show/hide with no transition.) A second, plain <TableRow>
// toggled by v-if is the correct table-native shape for an expandable
// row; it reuses Accordion's own visual language (a rotating chevron,
// the trigger row as a button) so it reads as the same interaction
// pattern without misusing components built for block content.
const manuallyExpanded = reactive(new Set<string>())
function toggleProvider(name: string): void {
if (manuallyExpanded.has(name)) manuallyExpanded.delete(name)
else manuallyExpanded.add(name)
}
// --- model/alias search filter (operator feature) ---
const modelQuery = ref('')
const normalizedQuery = computed(() => modelQuery.value.trim().toLowerCase())
const hasQuery = computed(() => normalizedQuery.value.length > 0)
function modelMatches(modelId: string): boolean {
return modelId.toLowerCase().includes(normalizedQuery.value)
}
function providerMatches(p: AdminProviderView): boolean {
return p.models.some(modelMatches)
}
function aliasMatches(a: AdminAliasView): boolean {
return a.alias.toLowerCase().includes(normalizedQuery.value) || a.target.toLowerCase().includes(normalizedQuery.value)
}
const filteredProviders = computed<AdminProviderView[]>(() => {
const all = overview.value?.providers ?? []
return hasQuery.value ? all.filter(providerMatches) : all
})
const filteredAliases = computed<AdminAliasView[]>(() => {
const all = overview.value?.aliases ?? []
return hasQuery.value ? all.filter(aliasMatches) : all
})
/**
* expandedProviders is what the template reads to decide which rows show
* their model list: the user's own manual toggles (manuallyExpanded),
* plus — only while a search query is active — every provider
* filteredProviders kept (they were kept because they have a matching
* model, so auto-expanding them surfaces the match without an extra
* click). Clearing the query drops this back to exactly
* manuallyExpanded, which manualExpanded's own toggle is the only thing
* that ever writes to — the "restore collapsed state when cleared"
* requirement falls out for free, because the auto-expand set was never
* a manual toggle to begin with.
*/
const expandedProviders = computed<Set<string>>(() => {
if (!hasQuery.value) return manuallyExpanded
const expanded = new Set(manuallyExpanded)
for (const p of filteredProviders.value) expanded.add(p.name)
return expanded
})
/** visibleModels is p's own model list, filtered to matches while a query is active — p only appears in filteredProviders at all because it has one, so this is never empty in that case; it exists so the accordion shows WHICH models matched instead of re-showing all 70+ with no distinction. */
function visibleModels(p: AdminProviderView): string[] {
return hasQuery.value ? p.models.filter(modelMatches) : p.models
}
function clearQuery(): void {
modelQuery.value = ''
}
</script>
<template>
@@ -88,7 +175,33 @@ const overview = computed(() => dashboard.overview)
<Card>
<CardHeader>
<CardTitle>Providers</CardTitle>
<CardDescription>Every configured upstream and its last discovery refresh.</CardDescription>
<CardDescription>
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>
</CardHeader>
<CardContent>
<Table>
@@ -106,14 +219,50 @@ const overview = computed(() => dashboard.overview)
<TableEmpty v-if="!overview?.providers.length" :colspan="6" class="text-muted-foreground">
none
</TableEmpty>
<TableRow v-for="p in overview?.providers" :key="p.name">
<TableCell class="font-medium">{{ p.name }}</TableCell>
<TableCell class="text-muted-foreground">{{ p.type }}</TableCell>
<TableCell class="text-muted-foreground">{{ p.baseUrl }}</TableCell>
<TableCell class="text-right tabular-nums">{{ p.modelCount }}</TableCell>
<TableCell class="text-muted-foreground">{{ formatTimestamp(p.lastRefresh) }}</TableCell>
<TableCell class="text-destructive">{{ p.lastErr ?? '' }}</TableCell>
</TableRow>
<TableEmpty
v-else-if="hasQuery && filteredProviders.length === 0"
:colspan="6"
class="text-muted-foreground"
>
no providers match &quot;{{ modelQuery }}&quot;
</TableEmpty>
<template v-for="p in filteredProviders" :key="p.name">
<TableRow
role="button"
tabindex="0"
class="cursor-pointer select-none hover:bg-accent/50"
:aria-expanded="expandedProviders.has(p.name)"
:aria-controls="`provider-models-${p.name}`"
@click="toggleProvider(p.name)"
@keydown.enter.prevent="toggleProvider(p.name)"
@keydown.space.prevent="toggleProvider(p.name)"
>
<TableCell class="font-medium">
<span class="flex items-center gap-2">
<FontAwesomeIcon
:icon="faChevronRight"
class="size-3 shrink-0 text-muted-foreground transition-transform duration-150"
:class="expandedProviders.has(p.name) ? 'rotate-90' : ''"
aria-hidden="true"
/>
{{ p.name }}
</span>
</TableCell>
<TableCell class="text-muted-foreground">{{ p.type }}</TableCell>
<TableCell class="text-muted-foreground">{{ p.baseUrl }}</TableCell>
<TableCell class="text-right tabular-nums">{{ p.modelCount }}</TableCell>
<TableCell class="text-muted-foreground">{{ formatTimestamp(p.lastRefresh) }}</TableCell>
<TableCell class="text-destructive">{{ p.lastErr ?? '' }}</TableCell>
</TableRow>
<TableRow v-if="expandedProviders.has(p.name)" :id="`provider-models-${p.name}`">
<TableCell colspan="6" class="whitespace-normal bg-muted/30">
<div v-if="visibleModels(p).length" class="flex flex-wrap gap-1.5 py-1">
<ModelChip v-for="m in visibleModels(p)" :key="m" :id="routableModelId(p.name, m)" />
</div>
<p v-else class="py-1 text-sm text-muted-foreground">no models known yet</p>
</TableCell>
</TableRow>
</template>
</TableBody>
</Table>
</CardContent>
@@ -136,7 +285,14 @@ const overview = computed(() => dashboard.overview)
<TableEmpty v-if="!overview?.aliases.length" :colspan="2" class="text-muted-foreground">
none configured
</TableEmpty>
<TableRow v-for="a in overview?.aliases" :key="a.alias">
<TableEmpty
v-else-if="hasQuery && filteredAliases.length === 0"
:colspan="2"
class="text-muted-foreground"
>
no aliases match &quot;{{ modelQuery }}&quot;
</TableEmpty>
<TableRow v-for="a in filteredAliases" :key="a.alias">
<TableCell class="font-medium">{{ a.alias }}</TableCell>
<TableCell class="text-muted-foreground">{{ a.target }}</TableCell>
</TableRow>
+33
View File
@@ -0,0 +1,33 @@
/**
* copyText writes text to the system clipboard via the Clipboard API
* (navigator.clipboard.writeText), available only in secure contexts
* (https, or localhost) with a user gesture — both satisfied by a click
* handler on an https-served admin panel. When the API is unavailable or
* the write itself rejects (permission denied, insecure context), falls
* back to SELECTING sourceEl's text content so the user can copy it
* manually (Ctrl/Cmd+C) — not `document.execCommand('copy')`, which is
* deprecated and needs the exact same secure-context/permission
* conditions that already ruled out the Clipboard API, so it would add
* complexity without adding a real fallback path.
*
* Returns which outcome happened, so a caller can show the right
* affordance ("Copied" vs "Selected — press ⌘/Ctrl+C").
*/
export async function copyText(text: string, sourceEl?: HTMLElement): Promise<'copied' | 'selected' | 'failed'> {
if (navigator.clipboard?.writeText) {
try {
await navigator.clipboard.writeText(text)
return 'copied'
} catch {
// Fall through to the selection fallback below.
}
}
if (!sourceEl) return 'failed'
const range = document.createRange()
range.selectNodeContents(sourceEl)
const selection = window.getSelection()
if (!selection) return 'failed'
selection.removeAllRanges()
selection.addRange(range)
return 'selected'
}
+15
View File
@@ -2,6 +2,21 @@ import type { HistoryWindow, LimitsConfig } from '@/types/api'
const ZERO_TIME = '0001-01-01T00:00:00Z'
/**
* routableModelId joins a provider name and one of its own model ids into
* the id form the gateway actually routes on (routes_unified.go's
* "provider/model" prefix rule) — always `${providerName}/${modelId}`,
* even when modelId itself already contains a slash (a discovered id
* like "uni/deepseek-v4-flash-0731" on provider "real" becomes
* "real/uni/deepseek-v4-flash-0731": the FIRST path segment names the
* provider, everything after it is the model id verbatim, so this stays
* copy-paste-valid regardless of how many slashes the model id itself
* carries).
*/
export function routableModelId(providerName: string, modelId: string): string {
return `${providerName}/${modelId}`
}
/** formatCost renders a micro-USD integer (adminUsageEntryView's convention) as "$1.2345". */
export function formatCost(micros: number): string {
return `$${(micros / 1_000_000).toFixed(4)}`
+2
View File
@@ -18,6 +18,8 @@ export interface AdminProviderView {
baseUrl: string
lastRefresh: string
lastErr?: string
/** Sorted explicit∪discovered model ids (admin.go: adminProviderView.Models) — always an array, never omitted, even when empty. */
models: string[]
modelCount: number
}