mirror of
https://github.com/lukaszraczylo/traefik-llmgateway.git
synced 2026-10-14 05:18:54 +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:
@@ -27,6 +27,7 @@ import (
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
@@ -758,6 +759,34 @@ func adminUsageEntry(t *testing.T, kind, id string) map[string]any {
|
||||
return nil
|
||||
}
|
||||
|
||||
// adminProviderModels finds name's own "models" array within providers (an
|
||||
// already-decoded GET /admin/api/overview "providers" list) and returns it
|
||||
// as a []string — the provider-model-accordion feature's field
|
||||
// (adminProviderView.Models, admin.go).
|
||||
func adminProviderModels(t *testing.T, providers []any, name string) []string {
|
||||
t.Helper()
|
||||
for _, p := range providers {
|
||||
provider, ok := p.(map[string]any)
|
||||
if !ok || provider["name"] != name {
|
||||
continue
|
||||
}
|
||||
raw, ok := provider["models"].([]any)
|
||||
if !ok {
|
||||
t.Fatalf("provider %q: models is not an array, got %#v", name, provider["models"])
|
||||
}
|
||||
out := make([]string, len(raw))
|
||||
for i, m := range raw {
|
||||
out[i], ok = m.(string)
|
||||
if !ok {
|
||||
t.Fatalf("provider %q: models[%d] is not a string, got %#v", name, i, m)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
t.Fatalf("no provider named %q in %#v", name, providers)
|
||||
return nil
|
||||
}
|
||||
|
||||
// adminTotalTokensPerDay sums an admin usage entry's tokensInPerDay and
|
||||
// tokensOutPerDay (v0.2 data-layer task: the JSON API split what was
|
||||
// previously a single combined tokensPerDay field into in/out — see
|
||||
@@ -1056,6 +1085,18 @@ func TestAdminDashboard(t *testing.T) {
|
||||
t.Fatalf("GET /admin/api/overview: expected a non-empty providers list, got %#v", overviewBody)
|
||||
}
|
||||
|
||||
// provider-model-accordion feature: each provider's own "models" array
|
||||
// (registry.go's providerSnapshot.models, admin.go's
|
||||
// adminProviderView.Models) drives the dashboard's expandable provider
|
||||
// row. "openai" (dynamic.yml.tmpl) configures an explicit
|
||||
// models: ["gpt-mock"] — present under real Traefik+Yaegi regardless
|
||||
// of whether discovery has added anything else, unlike a
|
||||
// discovery-only model this suite cannot control the timing of.
|
||||
openaiModels := adminProviderModels(t, providers, "openai")
|
||||
if !slices.Contains(openaiModels, "gpt-mock") {
|
||||
t.Errorf("openai provider models = %v, want it to contain %q (dynamic.yml.tmpl's explicit model)", openaiModels, "gpt-mock")
|
||||
}
|
||||
|
||||
nonAdminResp, nonAdminBody := doJSON(t, http.MethodGet, traefik1URL+"/admin/api/overview", aliceKey, nil)
|
||||
if nonAdminResp.StatusCode != http.StatusForbidden {
|
||||
t.Fatalf("GET /admin/api/overview (non-admin key): status = %d, want 403, body=%#v", nonAdminResp.StatusCode, nonAdminBody)
|
||||
|
||||
Reference in New Issue
Block a user