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:
@@ -259,7 +259,14 @@ type adminProviderView struct {
|
|||||||
BaseURL string `json:"baseUrl"`
|
BaseURL string `json:"baseUrl"`
|
||||||
LastRefresh time.Time `json:"lastRefresh"`
|
LastRefresh time.Time `json:"lastRefresh"`
|
||||||
LastErr string `json:"lastErr,omitempty"`
|
LastErr string `json:"lastErr,omitempty"`
|
||||||
ModelCount int `json:"modelCount"`
|
// Models is the sorted explicit∪discovered model id set
|
||||||
|
// (registry.go's providerSnapshot.models — provider-model-accordion
|
||||||
|
// task, Vue admin panel): the dashboard's expandable provider row.
|
||||||
|
// Never nil for a real provider — even zero known models marshals as
|
||||||
|
// "[]", not omitted, so the dashboard can tell "no models yet" apart
|
||||||
|
// from a field a stale client build does not know how to read.
|
||||||
|
Models []string `json:"models"`
|
||||||
|
ModelCount int `json:"modelCount"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// adminRedisView is the redis status line in GET /admin/api/overview.
|
// adminRedisView is the redis status line in GET /admin/api/overview.
|
||||||
@@ -341,6 +348,7 @@ func (g *Gateway) buildAdminOverview() adminOverviewResponse {
|
|||||||
Name: s.name,
|
Name: s.name,
|
||||||
Type: s.typeName,
|
Type: s.typeName,
|
||||||
BaseURL: sanitizeBaseURL(s.baseURL),
|
BaseURL: sanitizeBaseURL(s.baseURL),
|
||||||
|
Models: s.models,
|
||||||
ModelCount: s.modelCount,
|
ModelCount: s.modelCount,
|
||||||
LastRefresh: s.lastRefresh,
|
LastRefresh: s.lastRefresh,
|
||||||
LastErr: sanitizeProviderErr(s.lastErr, s.baseURL),
|
LastErr: sanitizeProviderErr(s.lastErr, s.baseURL),
|
||||||
|
|||||||
+3846
-3742
File diff suppressed because it is too large
Load Diff
@@ -10,6 +10,7 @@ import (
|
|||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"net/url"
|
"net/url"
|
||||||
"regexp"
|
"regexp"
|
||||||
|
"slices"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
@@ -241,6 +242,16 @@ func TestAdminOverview_SortedShapeAndVersion(t *testing.T) {
|
|||||||
if got.Providers[1].ModelCount != 1 {
|
if got.Providers[1].ModelCount != 1 {
|
||||||
t.Errorf("zeta modelCount = %d, want 1 (z-model)", got.Providers[1].ModelCount)
|
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 != "" {
|
if got.Providers[0].LastErr != "" || got.Providers[1].LastErr != "" {
|
||||||
t.Errorf("providers with discovery disabled must have empty lastErr, got %+v", got.Providers)
|
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 {
|
if p.ModelCount != 0 {
|
||||||
t.Errorf("modelCount = %d, want 0 (no explicit models, discovery failed)", p.ModelCount)
|
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() {
|
if p.LastRefresh.IsZero() {
|
||||||
t.Error("lastRefresh must be set even on a failed refresh (finishRefresh always advances it)")
|
t.Error("lastRefresh must be set even on a failed refresh (finishRefresh always advances it)")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ import (
|
|||||||
"os/exec"
|
"os/exec"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"regexp"
|
"regexp"
|
||||||
|
"slices"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
@@ -758,6 +759,34 @@ func adminUsageEntry(t *testing.T, kind, id string) map[string]any {
|
|||||||
return nil
|
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
|
// adminTotalTokensPerDay sums an admin usage entry's tokensInPerDay and
|
||||||
// tokensOutPerDay (v0.2 data-layer task: the JSON API split what was
|
// tokensOutPerDay (v0.2 data-layer task: the JSON API split what was
|
||||||
// previously a single combined tokensPerDay field into in/out — see
|
// 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)
|
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)
|
nonAdminResp, nonAdminBody := doJSON(t, http.MethodGet, traefik1URL+"/admin/api/overview", aliceKey, nil)
|
||||||
if nonAdminResp.StatusCode != http.StatusForbidden {
|
if nonAdminResp.StatusCode != http.StatusForbidden {
|
||||||
t.Fatalf("GET /admin/api/overview (non-admin key): status = %d, want 403, body=%#v", nonAdminResp.StatusCode, nonAdminBody)
|
t.Fatalf("GET /admin/api/overview (non-admin key): status = %d, want 403, body=%#v", nonAdminResp.StatusCode, nonAdminBody)
|
||||||
|
|||||||
+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,
|
// snapshot returns st's read-only view for the admin dashboard (spec §4,
|
||||||
// v0.2): its known model count (explicit ∪ discovered, mirroring
|
// v0.2): its known model ids (explicit ∪ discovered, sorted — the
|
||||||
// knownIDs), last refresh time, and last refresh error message.
|
// provider-model-accordion task; a caller wanting just the count uses
|
||||||
func (st *providerState) snapshot() (modelCount int, lastRefresh time.Time, lastErr string) {
|
// 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()
|
st.mu.Lock()
|
||||||
defer st.mu.Unlock()
|
defer st.mu.Unlock()
|
||||||
set := make(map[string]bool, len(st.explicit)+len(st.discovered))
|
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 {
|
for id := range st.discovered {
|
||||||
set[id] = true
|
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
|
// 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-
|
// 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
|
// exposed list is API keys, digests, redis password, and users-file path
|
||||||
// contents only) — it is included so an operator can see which upstream
|
// 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 {
|
type providerSnapshot struct {
|
||||||
lastRefresh time.Time
|
lastRefresh time.Time
|
||||||
name string
|
name string
|
||||||
typeName string
|
typeName string
|
||||||
baseURL string
|
baseURL string
|
||||||
lastErr string
|
lastErr string
|
||||||
|
models []string
|
||||||
modelCount int
|
modelCount int
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -787,12 +821,19 @@ func (m *modelRegistry) snapshot() []providerSnapshot {
|
|||||||
out := make([]providerSnapshot, 0, len(m.providerNames))
|
out := make([]providerSnapshot, 0, len(m.providerNames))
|
||||||
for _, name := range m.providerNames {
|
for _, name := range m.providerNames {
|
||||||
adapter := m.adapters[name]
|
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{
|
out = append(out, providerSnapshot{
|
||||||
name: adapter.name(),
|
name: adapter.name(),
|
||||||
typeName: adapter.typeName(),
|
typeName: adapter.typeName(),
|
||||||
baseURL: adapter.base(),
|
baseURL: adapter.base(),
|
||||||
modelCount: modelCount,
|
models: models,
|
||||||
|
modelCount: len(models),
|
||||||
lastRefresh: lastRefresh,
|
lastRefresh: lastRefresh,
|
||||||
lastErr: lastErr,
|
lastErr: lastErr,
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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) ---
|
// --- listFor: group filtering, sorting, collision presentation (ruling h) ---
|
||||||
|
|
||||||
func TestModelRegistry_ListFor_GroupFiltered(t *testing.T) {
|
func TestModelRegistry_ListFor_GroupFiltered(t *testing.T) {
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { faCheck, faCopy } from '@fortawesome/free-solid-svg-icons'
|
||||||
|
import { ref, useTemplateRef } from 'vue'
|
||||||
|
|
||||||
|
import { Badge } from '@/components/ui/badge'
|
||||||
|
import { copyText } from '@/lib/clipboard'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ModelChip is one clickable, copyable model-id chip in a provider's
|
||||||
|
* expanded model list (OverviewView.vue's provider-model accordion). id
|
||||||
|
* is the full routable id (lib/format.ts's routableModelId) — what
|
||||||
|
* actually gets copied, so a pasted value works straight into a
|
||||||
|
* `model: "..."` request field.
|
||||||
|
*/
|
||||||
|
const props = defineProps<{ id: string }>()
|
||||||
|
|
||||||
|
const labelRef = useTemplateRef<HTMLElement>('label')
|
||||||
|
const state = ref<'idle' | 'copied' | 'selected'>('idle')
|
||||||
|
let resetTimer: ReturnType<typeof setTimeout> | undefined
|
||||||
|
|
||||||
|
async function onClick(): Promise<void> {
|
||||||
|
const result = await copyText(props.id, labelRef.value ?? undefined)
|
||||||
|
if (result === 'failed') return
|
||||||
|
state.value = result
|
||||||
|
clearTimeout(resetTimer)
|
||||||
|
resetTimer = setTimeout(() => {
|
||||||
|
state.value = 'idle'
|
||||||
|
}, 1500)
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Badge
|
||||||
|
as="button"
|
||||||
|
type="button"
|
||||||
|
variant="secondary"
|
||||||
|
class="cursor-pointer gap-1 font-mono text-xs font-normal transition-colors hover:bg-accent hover:text-accent-foreground"
|
||||||
|
:title="state === 'idle' ? `Copy ${id}` : state === 'copied' ? 'Copied' : 'Selected — press Ctrl/Cmd+C'"
|
||||||
|
@click="onClick"
|
||||||
|
>
|
||||||
|
<span ref="label">{{ id }}</span>
|
||||||
|
<FontAwesomeIcon :icon="state === 'idle' ? faCopy : faCheck" class="size-2.5 shrink-0" aria-hidden="true" />
|
||||||
|
</Badge>
|
||||||
|
</template>
|
||||||
@@ -1,7 +1,15 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { faCircleCheck, faCircleXmark, faTriangleExclamation } from '@fortawesome/free-solid-svg-icons'
|
import {
|
||||||
import { computed } from 'vue'
|
faChevronRight,
|
||||||
|
faCircleCheck,
|
||||||
|
faCircleXmark,
|
||||||
|
faMagnifyingGlass,
|
||||||
|
faTriangleExclamation,
|
||||||
|
faXmark,
|
||||||
|
} from '@fortawesome/free-solid-svg-icons'
|
||||||
|
import { computed, reactive, ref } from 'vue'
|
||||||
|
|
||||||
|
import ModelChip from '@/components/ModelChip.vue'
|
||||||
import {
|
import {
|
||||||
Card,
|
Card,
|
||||||
CardContent,
|
CardContent,
|
||||||
@@ -9,6 +17,7 @@ import {
|
|||||||
CardHeader,
|
CardHeader,
|
||||||
CardTitle,
|
CardTitle,
|
||||||
} from '@/components/ui/card'
|
} from '@/components/ui/card'
|
||||||
|
import { Input } from '@/components/ui/input'
|
||||||
import {
|
import {
|
||||||
Table,
|
Table,
|
||||||
TableBody,
|
TableBody,
|
||||||
@@ -18,11 +27,89 @@ import {
|
|||||||
TableHeader,
|
TableHeader,
|
||||||
TableRow,
|
TableRow,
|
||||||
} from '@/components/ui/table'
|
} from '@/components/ui/table'
|
||||||
import { formatAgo, formatTimestamp } from '@/lib/format'
|
import { formatAgo, formatTimestamp, routableModelId } from '@/lib/format'
|
||||||
import { useDashboardStore } from '@/stores/dashboard'
|
import { useDashboardStore } from '@/stores/dashboard'
|
||||||
|
import type { AdminAliasView, AdminProviderView } from '@/types/api'
|
||||||
|
|
||||||
const dashboard = useDashboardStore()
|
const dashboard = useDashboardStore()
|
||||||
const overview = computed(() => dashboard.overview)
|
const overview = computed(() => dashboard.overview)
|
||||||
|
|
||||||
|
// --- provider row expand/collapse ---
|
||||||
|
//
|
||||||
|
// Providers table rows expand to a model list (operator feature). This is
|
||||||
|
// NOT shadcn-vue's Accordion or Collapsible component: both wrap
|
||||||
|
// trigger+content in one <div>, which cannot legally sit between two
|
||||||
|
// <tr> elements inside a <table>/<tbody> — a browser's HTML table parser
|
||||||
|
// reparents (hoists) a <div> found there out of the table, breaking the
|
||||||
|
// layout. (reka-ui's Collapsible primitives DO support an `as` prop that
|
||||||
|
// could in principle render Root as a <tbody> and Trigger/Content as
|
||||||
|
// <tr> — installed and inspected via the CLI to check, then not used:
|
||||||
|
// CollapsibleContent's animation-measurement code calls
|
||||||
|
// getBoundingClientRect() and sets inline transition/animation styles on
|
||||||
|
// whatever element `as` names, an interaction with a <tr> this component
|
||||||
|
// was never designed around and was not worth taking on for an
|
||||||
|
// instant show/hide with no transition.) A second, plain <TableRow>
|
||||||
|
// toggled by v-if is the correct table-native shape for an expandable
|
||||||
|
// row; it reuses Accordion's own visual language (a rotating chevron,
|
||||||
|
// the trigger row as a button) so it reads as the same interaction
|
||||||
|
// pattern without misusing components built for block content.
|
||||||
|
const manuallyExpanded = reactive(new Set<string>())
|
||||||
|
function toggleProvider(name: string): void {
|
||||||
|
if (manuallyExpanded.has(name)) manuallyExpanded.delete(name)
|
||||||
|
else manuallyExpanded.add(name)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- model/alias search filter (operator feature) ---
|
||||||
|
const modelQuery = ref('')
|
||||||
|
const normalizedQuery = computed(() => modelQuery.value.trim().toLowerCase())
|
||||||
|
const hasQuery = computed(() => normalizedQuery.value.length > 0)
|
||||||
|
|
||||||
|
function modelMatches(modelId: string): boolean {
|
||||||
|
return modelId.toLowerCase().includes(normalizedQuery.value)
|
||||||
|
}
|
||||||
|
function providerMatches(p: AdminProviderView): boolean {
|
||||||
|
return p.models.some(modelMatches)
|
||||||
|
}
|
||||||
|
function aliasMatches(a: AdminAliasView): boolean {
|
||||||
|
return a.alias.toLowerCase().includes(normalizedQuery.value) || a.target.toLowerCase().includes(normalizedQuery.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
const filteredProviders = computed<AdminProviderView[]>(() => {
|
||||||
|
const all = overview.value?.providers ?? []
|
||||||
|
return hasQuery.value ? all.filter(providerMatches) : all
|
||||||
|
})
|
||||||
|
const filteredAliases = computed<AdminAliasView[]>(() => {
|
||||||
|
const all = overview.value?.aliases ?? []
|
||||||
|
return hasQuery.value ? all.filter(aliasMatches) : all
|
||||||
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* expandedProviders is what the template reads to decide which rows show
|
||||||
|
* their model list: the user's own manual toggles (manuallyExpanded),
|
||||||
|
* plus — only while a search query is active — every provider
|
||||||
|
* filteredProviders kept (they were kept because they have a matching
|
||||||
|
* model, so auto-expanding them surfaces the match without an extra
|
||||||
|
* click). Clearing the query drops this back to exactly
|
||||||
|
* manuallyExpanded, which manualExpanded's own toggle is the only thing
|
||||||
|
* that ever writes to — the "restore collapsed state when cleared"
|
||||||
|
* requirement falls out for free, because the auto-expand set was never
|
||||||
|
* a manual toggle to begin with.
|
||||||
|
*/
|
||||||
|
const expandedProviders = computed<Set<string>>(() => {
|
||||||
|
if (!hasQuery.value) return manuallyExpanded
|
||||||
|
const expanded = new Set(manuallyExpanded)
|
||||||
|
for (const p of filteredProviders.value) expanded.add(p.name)
|
||||||
|
return expanded
|
||||||
|
})
|
||||||
|
|
||||||
|
/** visibleModels is p's own model list, filtered to matches while a query is active — p only appears in filteredProviders at all because it has one, so this is never empty in that case; it exists so the accordion shows WHICH models matched instead of re-showing all 70+ with no distinction. */
|
||||||
|
function visibleModels(p: AdminProviderView): string[] {
|
||||||
|
return hasQuery.value ? p.models.filter(modelMatches) : p.models
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearQuery(): void {
|
||||||
|
modelQuery.value = ''
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -88,7 +175,33 @@ const overview = computed(() => dashboard.overview)
|
|||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>Providers</CardTitle>
|
<CardTitle>Providers</CardTitle>
|
||||||
<CardDescription>Every configured upstream and its last discovery refresh.</CardDescription>
|
<CardDescription>
|
||||||
|
Every configured upstream and its last discovery refresh. Search filters providers and aliases by
|
||||||
|
model id.
|
||||||
|
</CardDescription>
|
||||||
|
<div class="relative mt-2 max-w-sm">
|
||||||
|
<FontAwesomeIcon
|
||||||
|
:icon="faMagnifyingGlass"
|
||||||
|
class="pointer-events-none absolute top-1/2 left-2.5 size-3.5 -translate-y-1/2 text-muted-foreground"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
v-model="modelQuery"
|
||||||
|
type="text"
|
||||||
|
placeholder="Search models or aliases..."
|
||||||
|
aria-label="Search models or aliases"
|
||||||
|
class="pr-8 pl-8"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
v-if="hasQuery"
|
||||||
|
type="button"
|
||||||
|
aria-label="Clear search"
|
||||||
|
class="absolute top-1/2 right-2 -translate-y-1/2 rounded text-muted-foreground hover:text-foreground"
|
||||||
|
@click="clearQuery"
|
||||||
|
>
|
||||||
|
<FontAwesomeIcon :icon="faXmark" class="size-3.5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<Table>
|
<Table>
|
||||||
@@ -106,14 +219,50 @@ const overview = computed(() => dashboard.overview)
|
|||||||
<TableEmpty v-if="!overview?.providers.length" :colspan="6" class="text-muted-foreground">
|
<TableEmpty v-if="!overview?.providers.length" :colspan="6" class="text-muted-foreground">
|
||||||
none
|
none
|
||||||
</TableEmpty>
|
</TableEmpty>
|
||||||
<TableRow v-for="p in overview?.providers" :key="p.name">
|
<TableEmpty
|
||||||
<TableCell class="font-medium">{{ p.name }}</TableCell>
|
v-else-if="hasQuery && filteredProviders.length === 0"
|
||||||
<TableCell class="text-muted-foreground">{{ p.type }}</TableCell>
|
:colspan="6"
|
||||||
<TableCell class="text-muted-foreground">{{ p.baseUrl }}</TableCell>
|
class="text-muted-foreground"
|
||||||
<TableCell class="text-right tabular-nums">{{ p.modelCount }}</TableCell>
|
>
|
||||||
<TableCell class="text-muted-foreground">{{ formatTimestamp(p.lastRefresh) }}</TableCell>
|
no providers match "{{ modelQuery }}"
|
||||||
<TableCell class="text-destructive">{{ p.lastErr ?? '' }}</TableCell>
|
</TableEmpty>
|
||||||
</TableRow>
|
<template v-for="p in filteredProviders" :key="p.name">
|
||||||
|
<TableRow
|
||||||
|
role="button"
|
||||||
|
tabindex="0"
|
||||||
|
class="cursor-pointer select-none hover:bg-accent/50"
|
||||||
|
:aria-expanded="expandedProviders.has(p.name)"
|
||||||
|
:aria-controls="`provider-models-${p.name}`"
|
||||||
|
@click="toggleProvider(p.name)"
|
||||||
|
@keydown.enter.prevent="toggleProvider(p.name)"
|
||||||
|
@keydown.space.prevent="toggleProvider(p.name)"
|
||||||
|
>
|
||||||
|
<TableCell class="font-medium">
|
||||||
|
<span class="flex items-center gap-2">
|
||||||
|
<FontAwesomeIcon
|
||||||
|
:icon="faChevronRight"
|
||||||
|
class="size-3 shrink-0 text-muted-foreground transition-transform duration-150"
|
||||||
|
:class="expandedProviders.has(p.name) ? 'rotate-90' : ''"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
{{ p.name }}
|
||||||
|
</span>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell class="text-muted-foreground">{{ p.type }}</TableCell>
|
||||||
|
<TableCell class="text-muted-foreground">{{ p.baseUrl }}</TableCell>
|
||||||
|
<TableCell class="text-right tabular-nums">{{ p.modelCount }}</TableCell>
|
||||||
|
<TableCell class="text-muted-foreground">{{ formatTimestamp(p.lastRefresh) }}</TableCell>
|
||||||
|
<TableCell class="text-destructive">{{ p.lastErr ?? '' }}</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
<TableRow v-if="expandedProviders.has(p.name)" :id="`provider-models-${p.name}`">
|
||||||
|
<TableCell colspan="6" class="whitespace-normal bg-muted/30">
|
||||||
|
<div v-if="visibleModels(p).length" class="flex flex-wrap gap-1.5 py-1">
|
||||||
|
<ModelChip v-for="m in visibleModels(p)" :key="m" :id="routableModelId(p.name, m)" />
|
||||||
|
</div>
|
||||||
|
<p v-else class="py-1 text-sm text-muted-foreground">no models known yet</p>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
</template>
|
||||||
</TableBody>
|
</TableBody>
|
||||||
</Table>
|
</Table>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
@@ -136,7 +285,14 @@ const overview = computed(() => dashboard.overview)
|
|||||||
<TableEmpty v-if="!overview?.aliases.length" :colspan="2" class="text-muted-foreground">
|
<TableEmpty v-if="!overview?.aliases.length" :colspan="2" class="text-muted-foreground">
|
||||||
none configured
|
none configured
|
||||||
</TableEmpty>
|
</TableEmpty>
|
||||||
<TableRow v-for="a in overview?.aliases" :key="a.alias">
|
<TableEmpty
|
||||||
|
v-else-if="hasQuery && filteredAliases.length === 0"
|
||||||
|
:colspan="2"
|
||||||
|
class="text-muted-foreground"
|
||||||
|
>
|
||||||
|
no aliases match "{{ modelQuery }}"
|
||||||
|
</TableEmpty>
|
||||||
|
<TableRow v-for="a in filteredAliases" :key="a.alias">
|
||||||
<TableCell class="font-medium">{{ a.alias }}</TableCell>
|
<TableCell class="font-medium">{{ a.alias }}</TableCell>
|
||||||
<TableCell class="text-muted-foreground">{{ a.target }}</TableCell>
|
<TableCell class="text-muted-foreground">{{ a.target }}</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
|
|||||||
@@ -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'
|
||||||
|
}
|
||||||
@@ -2,6 +2,21 @@ import type { HistoryWindow, LimitsConfig } from '@/types/api'
|
|||||||
|
|
||||||
const ZERO_TIME = '0001-01-01T00:00:00Z'
|
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". */
|
/** formatCost renders a micro-USD integer (adminUsageEntryView's convention) as "$1.2345". */
|
||||||
export function formatCost(micros: number): string {
|
export function formatCost(micros: number): string {
|
||||||
return `$${(micros / 1_000_000).toFixed(4)}`
|
return `$${(micros / 1_000_000).toFixed(4)}`
|
||||||
|
|||||||
@@ -18,6 +18,8 @@ export interface AdminProviderView {
|
|||||||
baseUrl: string
|
baseUrl: string
|
||||||
lastRefresh: string
|
lastRefresh: string
|
||||||
lastErr?: string
|
lastErr?: string
|
||||||
|
/** Sorted explicit∪discovered model ids (admin.go: adminProviderView.Models) — always an array, never omitted, even when empty. */
|
||||||
|
models: string[]
|
||||||
modelCount: number
|
modelCount: number
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user