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
+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)}`