Files
traefik-llmgateway/webui
lukaszraczylo f9b289f1ea 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.
2026-08-21 10:18:57 +01:00
..
2026-08-21 10:18:57 +01:00
2026-08-21 09:21:41 +01:00
2026-08-21 09:21:41 +01:00
2026-08-21 09:21:41 +01:00

Admin panel (webui)

Source for the traefik-llmgateway read-only admin dashboard: Vue 3 + TypeScript + Pinia + Tailwind v4 + shadcn-vue + Chart.js, built with Vite.

See the repo root README.md's Admin section for what the dashboard shows and how its API/auth works, and the Development section for make admin-ui — the only way this directory's output (../admin_assets_gen.go) is meant to be regenerated. Nothing here runs at request time: the plugin serves the built, baked-in output, never webui/ itself.

Warning: shadcn-vue add/init regressions

components.json has no iconLibrary or font key. Do not add them back. Their omission is the fix, not an oversight — read on before you "helpfully" restore either one.

Earlier revisions of this file set "iconLibrary": "lucide" and "font": "inter", both wrong for this project (FontAwesome only, per the vue-web-development house skill; no external font at all). Both npx shadcn-vue init and npx shadcn-vue add <component> read those two fields and, when set, rewrite src/assets/main.css — hit twice in this project's own history, init then a later add, both re-adding a @import url('https://fonts.googleapis.com/...') line and switching the dark-mode @custom-variant back to a .dark-class toggle.

The actual shadcn-vue config schema (node_modules/shadcn-vue/dist/schema/index.js's rawConfigSchema) declares both fields as z.string().optional() — not an enum, contrary to an earlier, incorrect claim in this project's own review notes that the CLI schema forced one of five fixed values. The CLI's init --font/ --icon-library flags do offer five fixed choices each, but that is a flag-parsing constraint, not a components.json schema constraint. Deleting both keys leaves them undefined after parse, a valid state: verified empirically, running npx shadcn-vue add badge -o -y against a components.json with both keys removed left src/assets/main.css byte-for-byte unchanged.

That verification also surfaced a narrower, remaining trap: the same run still added @lucide/vue back to package.json's dependencies (Badge itself does not use it — some components' registry entries list an icon package regardless of iconLibrary). So after any future shadcn-vue add/init call:

  1. Check git diff webui/package.json for a re-added lucide-vue-next/@lucide/vue dependency, and remove it (npm uninstall) if nothing in src/ imports from it.
  2. Check the new component's own file for a lucide-vue-next/@lucide/vue import and swap it for the FontAwesome equivalent (see src/components/ui/select/Select*.vue for the pattern already applied there).
  3. Diff src/assets/main.css anyway before committing, in case a future shadcn-vue release starts reading a different config key for the same font-injection step.

Local development

npm ci
npm run dev      # Vite dev server — /admin/api/* calls need a real
                  # gateway to fetch from; point one at the same origin or
                  # accept the "refresh failed" state while iterating on
                  # layout/styling alone.
npm run build     # what `make admin-ui` runs, plus webui/generate.mjs

Structure

  • src/stores/ — Pinia: auth (the sessionStorage key gate), dashboard (Overview/Usage, 5s poll), history (Charts, fetch-on-selection + 30s refresh).
  • src/components/ — AuthGate, OverviewView, UsageView, ChartsView/UsageChart, UsageTable; ui/ holds the shadcn-vue primitives this panel actually uses (Card, Table, Tabs, Button, Input, Alert, Select, Badge) — nothing installed and unused.
  • src/lib/ — api.ts (the /admin/api/* fetch wrapper), format.ts, key-storage.ts, chart-setup.ts.
  • src/types/api.ts — TypeScript mirrors of admin.go's JSON response shapes (adminOverviewResponse, adminUsageResponse, usageHistoryResponse).
  • generate.mjs — reads dist/ after vite build and writes ../admin_assets_gen.go; see its own header comment for why every asset is base64-encoded rather than embedded as a raw string literal.