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
+17
View File
@@ -10,6 +10,7 @@ import (
"net/http/httptest"
"net/url"
"regexp"
"slices"
"strings"
"testing"
"time"
@@ -241,6 +242,16 @@ func TestAdminOverview_SortedShapeAndVersion(t *testing.T) {
if got.Providers[1].ModelCount != 1 {
t.Errorf("zeta modelCount = %d, want 1 (z-model)", got.Providers[1].ModelCount)
}
// models (provider-model-accordion task): the sorted id list backing
// modelCount above, not just its length.
wantAlphaModels := []string{"a-model-1", "a-model-2"}
if !slices.Equal(got.Providers[0].Models, wantAlphaModels) {
t.Errorf("alpha models = %v, want sorted %v", got.Providers[0].Models, wantAlphaModels)
}
wantZetaModels := []string{"z-model"}
if !slices.Equal(got.Providers[1].Models, wantZetaModels) {
t.Errorf("zeta models = %v, want %v", got.Providers[1].Models, wantZetaModels)
}
if got.Providers[0].LastErr != "" || got.Providers[1].LastErr != "" {
t.Errorf("providers with discovery disabled must have empty lastErr, got %+v", got.Providers)
}
@@ -356,6 +367,12 @@ func TestAdminOverview_ProviderLastErrAfterFailedRefresh(t *testing.T) {
if p.ModelCount != 0 {
t.Errorf("modelCount = %d, want 0 (no explicit models, discovery failed)", p.ModelCount)
}
if len(p.Models) != 0 {
t.Errorf("models = %v, want empty (no explicit models, discovery has never once succeeded)", p.Models)
}
if p.Models == nil {
t.Error("models must marshal as [] (a non-nil empty slice), not be omitted or null")
}
if p.LastRefresh.IsZero() {
t.Error("lastRefresh must be set even on a failed refresh (finishRefresh always advances it)")
}