mirror of
https://github.com/lukaszraczylo/traefik-llmgateway.git
synced 2026-10-18 05:47:38 +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:
@@ -653,6 +653,60 @@ func TestModelRegistry_MaybeRefresh_DiscoveryError_KeepsPreviousDiscoveredSet(t
|
||||
}
|
||||
}
|
||||
|
||||
// TestModelRegistry_Snapshot_ModelsStaleWhileError proves
|
||||
// modelRegistry.snapshot()'s providerSnapshot.models — the admin
|
||||
// dashboard's provider-model-accordion field (registry.go's
|
||||
// providerState.snapshot) — reflects the same stale-while-error set
|
||||
// hasModel/knownIDs already do: a refresh that fails after an earlier
|
||||
// successful one leaves the previously discovered models in the
|
||||
// snapshot, not an empty list. This is the existing semantics
|
||||
// finishRefresh's own doc comment already documents; this test applies
|
||||
// no special handling for the mid-refresh/failed case, it only asserts
|
||||
// the models field is populated from whatever snapshot() already reads.
|
||||
func TestModelRegistry_Snapshot_ModelsStaleWhileError(t *testing.T) {
|
||||
t.Parallel()
|
||||
var fail atomic.Bool
|
||||
fa := newFakeAdapter("openai")
|
||||
fa.listModelsFn = func(context.Context) ([]string, error) {
|
||||
if fail.Load() {
|
||||
return nil, fmt.Errorf("upstream unreachable")
|
||||
}
|
||||
return []string{"m2", "m1"}, nil // deliberately unsorted at the source
|
||||
}
|
||||
adapters := map[string]providerAdapter{"openai": fa}
|
||||
cfg := &Config{Providers: map[string]*ProviderConfig{"openai": {Discovery: true, DiscoveryInterval: "1h"}}}
|
||||
rl := &recordingLog{}
|
||||
reg, err := newModelRegistry(adapters, cfg, rl.fn)
|
||||
if err != nil {
|
||||
t.Fatalf("newModelRegistry: %v", err)
|
||||
}
|
||||
reg.warmFill(context.Background()) // succeeds, populates discovered={m1,m2}
|
||||
|
||||
snaps := reg.snapshot()
|
||||
if len(snaps) != 1 {
|
||||
t.Fatalf("snapshot = %+v, want 1 provider", snaps)
|
||||
}
|
||||
if got := snaps[0].models; len(got) != 2 || got[0] != "m1" || got[1] != "m2" {
|
||||
t.Fatalf("models after a successful discovery = %v, want sorted [m1 m2]", got)
|
||||
}
|
||||
|
||||
now := time.Now().Add(2 * time.Hour)
|
||||
reg.nowFn = func() time.Time { return now }
|
||||
fail.Store(true)
|
||||
before := rl.count()
|
||||
|
||||
reg.maybeRefresh(context.Background())
|
||||
waitUntil(t, time.Second, func() bool { return rl.count() > before })
|
||||
|
||||
snaps = reg.snapshot()
|
||||
if got := snaps[0].models; len(got) != 2 || got[0] != "m1" || got[1] != "m2" {
|
||||
t.Errorf("models after a failed refresh = %v, want the stale-while-error set [m1 m2] unchanged", got)
|
||||
}
|
||||
if snaps[0].lastErr == "" {
|
||||
t.Error("lastErr must be non-empty after the failed refresh, even though models stayed populated")
|
||||
}
|
||||
}
|
||||
|
||||
// --- listFor: group filtering, sorting, collision presentation (ruling h) ---
|
||||
|
||||
func TestModelRegistry_ListFor_GroupFiltered(t *testing.T) {
|
||||
|
||||
Reference in New Issue
Block a user