mirror of
https://github.com/lukaszraczylo/traefik-llmgateway.git
synced 2026-10-19 05:47:48 +00:00
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:
+48
-7
@@ -127,9 +127,33 @@ func (st *providerState) finishRefresh(now time.Time, ids []string, err error) {
|
||||
}
|
||||
|
||||
// snapshot returns st's read-only view for the admin dashboard (spec §4,
|
||||
// v0.2): its known model count (explicit ∪ discovered, mirroring
|
||||
// knownIDs), last refresh time, and last refresh error message.
|
||||
func (st *providerState) snapshot() (modelCount int, lastRefresh time.Time, lastErr string) {
|
||||
// v0.2): its known model ids (explicit ∪ discovered, sorted — the
|
||||
// provider-model-accordion task; a caller wanting just the count uses
|
||||
// len(models)), last refresh time, and last refresh error message.
|
||||
//
|
||||
// models is computed in the SAME critical section as lastRefresh/lastErr
|
||||
// below, not via a separate call to knownIDs() (which does the identical
|
||||
// explicit∪discovered merge-and-sort): st.mu is a plain sync.Mutex, not
|
||||
// reentrant, so calling knownIDs() — which takes the same lock — from
|
||||
// inside this already-locked method would deadlock. The duplication
|
||||
// mirrors this method's own pre-existing pattern (it already rebuilt the
|
||||
// same union set as knownIDs() independently, before this change, purely
|
||||
// for the count) rather than introducing a new one. Keeping the merge
|
||||
// inside one lock/unlock, rather than one call for the count and a
|
||||
// second, later call to knownIDs() for the list, also matters
|
||||
// functionally, not just stylistically: a concurrent finishRefresh
|
||||
// landing between two separate locked sections could otherwise hand the
|
||||
// admin dashboard a models list whose length disagrees with a
|
||||
// separately-read modelCount — reading both from one locked pass makes
|
||||
// that impossible.
|
||||
//
|
||||
// A provider whose discovery is mid-refresh (or has never refreshed
|
||||
// since a config reload) returns exactly this stale-while-error set —
|
||||
// finishRefresh's own doc comment covers why: a failed refresh keeps the
|
||||
// previous discovered set rather than clearing it. This method applies
|
||||
// no special handling for that case; it is simply the same set every
|
||||
// other snapshot consumer (modelCount, listFor) already reads.
|
||||
func (st *providerState) snapshot() (models []string, lastRefresh time.Time, lastErr string) {
|
||||
st.mu.Lock()
|
||||
defer st.mu.Unlock()
|
||||
set := make(map[string]bool, len(st.explicit)+len(st.discovered))
|
||||
@@ -139,7 +163,12 @@ func (st *providerState) snapshot() (modelCount int, lastRefresh time.Time, last
|
||||
for id := range st.discovered {
|
||||
set[id] = true
|
||||
}
|
||||
return len(set), st.lastRefresh, st.lastErr
|
||||
ids := make([]string, 0, len(set))
|
||||
for id := range set {
|
||||
ids = append(ids, id)
|
||||
}
|
||||
sort.Strings(ids)
|
||||
return ids, st.lastRefresh, st.lastErr
|
||||
}
|
||||
|
||||
// modelRegistry aggregates every configured provider's explicit and
|
||||
@@ -743,13 +772,18 @@ func modelObject(id, ownedBy string) map[string]any {
|
||||
// dashboard (spec §4, v0.2). baseURL is not secret (the spec's NEVER-
|
||||
// exposed list is API keys, digests, redis password, and users-file path
|
||||
// contents only) — it is included so an operator can see which upstream
|
||||
// a provider actually targets.
|
||||
// a provider actually targets. models — the sorted explicit∪discovered
|
||||
// id set (provider-model-accordion task) — is likewise not secret: a
|
||||
// model id names no credential, and this same set is already public
|
||||
// through /v1/models (listFor) to any authenticated caller the group
|
||||
// allows.
|
||||
type providerSnapshot struct {
|
||||
lastRefresh time.Time
|
||||
name string
|
||||
typeName string
|
||||
baseURL string
|
||||
lastErr string
|
||||
models []string
|
||||
modelCount int
|
||||
}
|
||||
|
||||
@@ -787,12 +821,19 @@ func (m *modelRegistry) snapshot() []providerSnapshot {
|
||||
out := make([]providerSnapshot, 0, len(m.providerNames))
|
||||
for _, name := range m.providerNames {
|
||||
adapter := m.adapters[name]
|
||||
modelCount, lastRefresh, lastErr := m.states[name].snapshot()
|
||||
// Three-value multi-assign straight from the call, no intermediate
|
||||
// named variables reused across a loop iteration boundary — Yaegi
|
||||
// has had multi-assign edge cases around reused/shadowed loop
|
||||
// variables in other parts of this codebase's history; this shape
|
||||
// (fresh locals every iteration, assigned once, read once) avoids
|
||||
// that class of trap entirely.
|
||||
models, lastRefresh, lastErr := m.states[name].snapshot()
|
||||
out = append(out, providerSnapshot{
|
||||
name: adapter.name(),
|
||||
typeName: adapter.typeName(),
|
||||
baseURL: adapter.base(),
|
||||
modelCount: modelCount,
|
||||
models: models,
|
||||
modelCount: len(models),
|
||||
lastRefresh: lastRefresh,
|
||||
lastErr: lastErr,
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user