mirror of
https://github.com/lukaszraczylo/traefik-llmgateway.git
synced 2026-10-13 04:58:50 +00:00
56e112a3ed85263c54546a2c4dacb5a47bedd32d
35
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
a05d45adb9 |
Merge pull request #2 from lukaszraczylo/dependabot/npm_and_yarn/webui/npm_and_yarn-8839a8980a
chore(deps): bump fast-uri from 3.1.5 to 3.1.7 in /webui in the npm_and_yarn group across 1 directory |
||
|
|
d8ba90c5d0 |
fix(admin): share the zero-time constant
The zero-time sentinel was module private, so the spec redeclared the literal once a third consumer started depending on it. Export it and import it in the spec. Regenerate the baked admin assets. Claude-Session: https://claude.ai/code/session_01EebV5jyWvYocByNQ3oss1w |
||
|
|
4848cca0a9 |
chore(deps): bump fast-uri
Bumps the npm_and_yarn group with 1 update in the /webui directory: [fast-uri](https://github.com/fastify/fast-uri). Updates `fast-uri` from 3.1.5 to 3.1.7 - [Release notes](https://github.com/fastify/fast-uri/releases) - [Commits](https://github.com/fastify/fast-uri/compare/v3.1.5...v3.1.7) --- updated-dependencies: - dependency-name: fast-uri dependency-version: 3.1.7 dependency-type: indirect dependency-group: npm_and_yarn ... Signed-off-by: dependabot[bot] <[email protected]> |
||
|
|
af84ed5eb7 |
feat(admin): show target health in the dashboard
Add a Health column to the MCP servers and A2A agents tables in TargetsView. An unhealthy target renders a destructive badge with the age of its last check and carries the failure count, source, and scrubbed last error in the title. An unknown target renders an outline badge and, when it has failed but never succeeded, the same failure details in its title. A healthy target renders muted text with its last observed latency and no badge, matching the providers' convention. Add AdminTargetHealthView to the API types with the wire contract the Go side emits, a bare formatElapsedAgo helper next to formatAgo, and column-cell tests for all three states. Regenerate the baked admin assets. Claude-Session: https://claude.ai/code/session_01EebV5jyWvYocByNQ3oss1w |
||
|
|
4bb49b4fe9 |
feat(admin): add per-model usage statistics to the charts section
The Charts view could break usage down by user, group or total, but not by model. Per-model data existed only as day-window attempt and failure counts, which measure availability rather than delivered usage. A served model now accumulates the same four metrics a user or group does. The scope kind is "model" and its id is the canonical "provider/model", so a request that fails over counts against the provider that answered it, and one bare id served by two providers ranks as two rows. The request counter is written after the response, in the batch that already writes tokens and cost. That is the first point at which the serving model is known, so no extra round trip is needed and admission never double counts. GET /admin/api/usage/models ranks models for one metric and window. It returns only models with non-zero usage, by operator requirement: the catalog runs to hundreds of models and a padded ranking is unreadable. The read stays one storeGetMulti over the catalog. The Charts view gains a Models tab with its own metric picker, and the scope picker gains the models that have traffic. |
||
|
|
0eb98eae64 |
feat(webui): show usage-provenance badges in the Providers tab
Add AdminProvenanceView and AdminProviderView.provenance to types/api.ts, mirroring admin.go's new fields, then surface two badges beside the existing health and latency badges in ProvidersView.vue: "estimated N" (secondary variant) and "unbilled N" (destructive variant), each with a hover title explaining what happened and why it matters. Neither badge renders when that provenance kind has no observations yet, matching the existing health/latency badges' own convention. Estimated and unbilled stay two separate badges, never merged into one indicator: collapsing them would hide the one thing this feature exists to surface, that a provider serving completions entirely free against every budget is a materially different failure than an approximated bill. Built with shadcn-vue's existing Badge component; no hand-rolled markup. |
||
|
|
3012586e8e |
feat(webui): show per-replica upstream latency in the Providers tab
Add streaming and non-streaming latency badges beside the discovery health badge on each provider row. Streaming shows average time to first byte, labeled ttfb, since it is the only load-sensitive figure this data provides. Non-streaming shows average total duration, labeled avg, never latency or ttfb, because a non-streaming provider buffers the whole completion before sending anything and its ttfb carries the same output-length contamination as its duration. Badges omit entirely when a provider has no observations yet, so absence never reads as a fabricated zero. Every badge title states the per-replica, in-process scope explicitly: this data comes from the same in-process accumulator the rate-limit counters use, never Redis, so a single poll reflects only whichever replica answered it. Mirror admin.go's adminLatencyView type in types/api.ts, keyed by the literal streaming/non-streaming union admin.go emits. Add formatLatencyMs to lib/format.ts with spec tests alongside its siblings in format.spec.ts. |
||
|
|
d0b55a37e9 |
fix(webui): match the health detail row color to its badge, add tests
ProvidersView.vue's accordion detail row hardcoded text-destructive red for any non-closed health state, while the trigger row's Badge deliberately used grey (secondary) for half-open and red (destructive) only for open. Add healthDetailClass so the detail row's color always matches the badge's own severity, instead of overstating a half-open probe as an error. Add a describe block for formatUntil to format.spec.ts, covering the unset sentinel, undefined, a future timestamp, and a past one. It had no test coverage since being added in the previous round. |
||
|
|
8a3f9e7d00 |
feat(webui): surface discovery health state in the Providers tab
Add healthState and openUntil to AdminProviderView, and a formatUntil helper alongside the existing formatAgo/formatTimestamp pair, rendering a future ISO timestamp as "in Ns". ProvidersView.vue shows a Badge next to a provider's other status indicators, destructive for open and secondary for half-open, using the existing shadcn-vue Badge component rather than a hand-rolled indicator. A closed provider, the healthy common case, renders no badge at all, matching the existing convention for the per-model rate badge. The expanded accordion content adds a matching detail row. This was the brief's stated motivation for the admin surfacing work: a broken provider looked identical to a working one in the dashboard. That gap was previously closed at the API layer only; this closes it in the UI clients actually see. Regenerates admin_assets_gen.go from the rebuilt webui via make admin-ui, per this repository's own convention of committing the generated asset alongside its source change. |
||
|
|
e5c3cd4b97 |
perf(admin): stop wrapping baked assets in a 7500-literal + chain
webui/generate.mjs split each admin-panel asset's base64 into 120-char Go string literals joined with `+` (~7,550 literals, ~7,538 `+` ops across index.html + the JS/CSS bundle). A compiled Go binary folds that at compile time for free, but this plugin loads under Yaegi (github.com/traefik/yaegi), which folds a chain of N literal `+` operations at import time by repeatedly reallocating and copying the whole already-folded prefix: O(N^2). Measured: ~186ms of a ~223-240ms package import was this folding alone, versus ~3-4ms to decode the asset bytes. Replace the 120-char LINE_WIDTH with a 1,000,000-char CHUNK_CHARS cap. Every asset this panel ships today (largest ~516,000 base64 chars) fits in a single literal, so goBase64Expr now emits one decodeAdminAsset call with zero `+` folds per asset. The cap still bounds a future, much larger bundle to a small number of large chunks rather than one unbounded line; single-line Go string literals in this size class were verified against gofmt/go vet/go build before picking the constant. No behavior change: decoded asset bytes, contentType strings, and the asset map keys are unaffected by how the base64 is chunked into source literals — see the following regen commit for the byte-identity proof. |
||
|
|
9b60c0afae |
fix(webui): ARIA-valid hover text, alias metadata guard, context-chip polish
CompactNumber.vue and ProviderRateBadge.vue both put aria-label on a
plain <span> — axe's aria-prohibited-attr rule: a <span>'s implicit
ARIA role ("generic") does not support accessible-name computation at
all, so the attribute was simply invalid there, not just discouraged.
Both now use the visible-text-hidden-plus-sr-only-full-text pattern
instead: the compact/label text is marked aria-hidden="true", and a
sr-only sibling span carries the exact value/detail for assistive tech.
title stays on the outer element for mouse hover, unchanged. Folded
review minor.
ProvidersView.vue: the alias table's ModelChip cell read
row.original.modelMeta.contextTokens without a guard, unlike the
provider path's own modelMetaFor fallback — a response from a server
build older than this field (version skew) would throw instead of just
omitting the hover detail. Now optional-chained to match. The visible
context chip beside each model gains title="context window" and
text-xs (was an arbitrary text-[10px]), matching the rest of the panel's
muted-label sizing convention. Folded review minors.
|
||
|
|
5e3cf919d9 |
fix(webui): compact-format precision bugs (review IMPORTANT-2/3)
formatCompactCount: Math.floor(scaled*factor)/factor under-reported by one unit whenever the intermediate scaled=abs/divisor value was not exactly representable in binary floating point (8_700_000 -> "8.69M", not "8.7M" — 8.7 itself has no exact double representation, so the original two-step division/floor landed on 869 instead of 870). Reordered to floor(abs*factor/divisor) — multiplying the original INTEGER first keeps every intermediate value exact for any realistic count, since abs*factor stays within Number.MAX_SAFE_INTEGER. New rows: 8_700_000 -> "8.7M", 1_130_000 -> "1.13M". (review IMPORTANT-2) formatContextWindow: always divided by 1024 (binary-K), which is right for a genuinely binary context size (262144 -> "256k") but wrong for a decimal, marketing-style round count (128000 -> "125k", an odd number nobody would recognize as "128K"). A plain "tokens % 1024 == 0" check cannot separate these — 128000 is ALSO an exact multiple of 1024 (125 x 1024) despite not being a power of two. The actual, consistent discriminator is POWER OF TWO: 262144 = 2^18, 128000 = 2^10 x 5^3 (not a power of two). Binary-K/M now applies only when tokens is an exact power of two; decimal-K/M otherwise. Fixed rows: 128000 -> "128k" (was "125k"), 1_000_000 -> "1M" (was "977k"), 2_000_000 -> "2M" (was "1953k"); 262144 -> "256k" and 32768 -> "32k" unchanged. (review IMPORTANT-3) |
||
|
|
040ff40ae0 |
feat(webui): compact usage-count formatting
Operator scope addition: raw token/request counts reaching millions/
billions were unreadable as bare digits. formatCompactCount (lib/
format.ts, shipped in the previous commit alongside this feature's own
helpers) renders an SI-style label: below 10,000 the exact integer with
thousands separators, at or above it three significant figures with a
k/M/B suffix ("12.3k", "456k", "1.23M", "45.6M", "1.23B") —
every digit past the third significant one is FLOORED, never rounded,
the same honesty rule formatRatePercent already applies to the
success-rate badge: a count of 999,950 must read "999k", never
"1,000k", since it has not actually crossed that boundary.
CompactNumber.vue is the one shared, reusable presentational component
(component-first: no per-call-site wrapper markup) — renders the
compact form as text, with the exact value on both title and aria-label,
mirroring ProviderRateBadge.vue/ModelChip.vue's own hover-parity
pattern.
Applied everywhere a count renders: usage-columns.ts's numericColumn
(reqMin/reqDay/tokIn-out per day and month — cost columns explicitly
opt out via a new compact:false argument, since formatCost already
shows full precision), target-columns.ts's MCP/agent request counters,
UsageView.vue's Total card and group-accordion trigger/detail stat
grid, and UsageChart.vue's Chart.js y-axis ticks (compact only — no
room for the raw figure on an axis label) and tooltip labels (compact
form WITH the precise raw value alongside, e.g. "Tokens in: 1.23M
(1,234,567)").
Sorting is unaffected by construction: every numericColumn's accessorFn
still returns the raw number (what getSortedRowModel actually sorts by)
regardless of what the cell renders — usage-columns.spec.ts pins this
directly, including a sort-order assertion that would fail under a
lexicographic (string) sort but passes under the numeric one.
|
||
|
|
eac0862d68 |
feat(admin,webui): metadata exposure
admin.go: adminProviderView gains ModelMeta (map[string]adminModelMetaView,
keyed by upstream model id) and adminAliasView gains its own ModelMeta —
both built via g.registry.resolveMetaFor/resolveMetaForAliasName
(feature v0.23). adminModelMetaView uses pointer fields
(*int/*float64), not plain zero values: a nil pointer means unknown, a
non-nil pointer (even one pointing at 0) means known, so a free model's
real zero cost never gets confused with an absent one over JSON.
resolveMetaForAliasName resolves an alias's metadata for this
unrestricted admin view via an internal &group{} (GroupConfig's own
documented empty-means-all semantics) — buildAdminOverview already
shows every provider/alias unconditionally, with no group filtering
anywhere else in its response either.
webui/src/types/api.ts: AdminModelMetaView mirrors the Go shape
(optional numbers, undefined means unknown); AdminProviderView.modelMeta
and AdminAliasView.modelMeta wire it through.
webui/src/lib/format.ts: formatContextWindow (binary-K "256k"-style),
formatModelCostHover ("in $X / out $Y per MTok", or "free"), and
their shared formatUsdPerMTok building block.
ModelChip.vue (operator scope refinement): every chip — the accordion's
model chips, the alias table's chips, a group's usage-detail chips —
gains optional contextTokens/inputPerMTokUsd/outputPerMTokUsd props.
When known, they append a resolved-metadata phrase to the existing
copy-state hover text, bound to BOTH title and aria-label (the same
mouse-vs-keyboard/screen-reader parity ProviderRateBadge.vue already
applies); when nothing is known, the hover is unchanged from before this
feature. An alias's chip carries its INHERITED metadata, matching the
alias-inheritance rule the backend already applies.
ProvidersView.vue: the accordion's model list gains a visible, muted
context-only chip ("256k") beside each ModelChip — cost stays
hover-only on the chip itself, per the operator's own "chips +
hover would be redundant on price" resolution.
|
||
|
|
7eac56cb46 |
docs(webui): fix stale percent in minuteRateTitle doc example
SHOULD-B (v0.22 review round, round 2): the doc comment still showed "(92%)" for 12 attempts/1 failure — stale against the floor-not-round fix (formatRatePercent) landed earlier in this same file, which the spec's own test asserts as 91%. No functional change; comment only. No admin-asset regen needed — verified byte-identical dist/ output before and after (a doc comment does not reach the minified bundle). |
||
|
|
abbf5d0967 |
fix(webui): accessible warn token, day-window model badges, review minors
Review round (v0.22) — MUST-1, SHOULD-2's frontend half, and every folded minor that touched webui/. MUST-1 (contrast): the degraded badge's amber previously reused --color-chart-tokens-out, tuned for a chart mark, not body text — measured ~2.22:1 against --card in light mode, a WCAG 1.4.3 failure on exactly the "something's wrong" tier. A new semantic --status-warn token pair (main.css, with a @theme inline mapping) replaces it: #a35c00 light (~5.06:1), #f0ac1f dark (~8.97:1, already passing, redefined anyway so every semantic-color token's dark value lives in one place). ProviderRateBadge.vue's degraded tier now uses border-status-warn/40 text-status-warn. SHOULD-2 (frontend half — see the backend commit for admin.go/limits.go): AdminModelRateView drops attemptsMinute/failuresMinute (the backend no longer sends them at all). ProviderRateBadge.vue's attempts-minute/ failures-minute props become optional; their absence (not a passed- through 0) triggers a new dayRateTitle fallback (lib/provider-rate.ts) — a real "0 attempts this minute" and "no minute data fetched at all" read identically as 0 otherwise, which would misreport a model badge as having live-minute data it never measured. Folded minors: - formatRatePercent floors instead of rounds — only a true 100% rate (zero failures) ever renders "100%"; 99.6% now shows "99%", not a round-tripped "100%" that would overstate it. - providerSuccessRate NaN-guards both inputs (Number.isFinite). - ProvidersView's modelRateFor uses `p.modelRates?.[model]` — a stale client against an older server build could otherwise throw rather than fall back, matching its own doc comment's stated intent. - ProviderRateBadge's title is now also its aria-label — title alone is mouse-only; a keyboard/screen-reader user previously got none of the minute/day detail. - The accordion's "Last refresh" DETAIL row (not just the trigger's compact text) now shares refreshLabel's three-state logic via a new refreshDetailLabel (lib/format.ts) — it previously called formatTimestamp directly, so a discovery-off provider's expanded row still said "never" beside a trigger that correctly said "discovery off". - providerModelScopeID's Go doc comment (limits.go, backend commit) cross-references configNamePattern for why the "/" join is collision- safe even though a model id may itself contain "/". - useTabHash.ts documents its two intentional URL-normalization quirks: case-sensitive hash matching, and an invalid/absent initial hash normalizing only in memory, not in the address bar, until the next tab change. |
||
|
|
2298249df5 |
feat(webui): rate badges, refresh labels, anchor links
Providers tab (Feature A): a new ProviderRateBadge component renders each provider's day-window success rate (attempts-failures)/attempts — always shown when attempts>0 (100% muted, <99% amber via the chart palette's --color-chart-tokens-out token, <90% destructive, reusing the Badge component's existing secondary/outline/destructive idioms rather than inventing a new variant), a distinct "no traffic" badge when attempts=0, and the live minute-window count in its title attribute. The same component renders beside a ModelChip inside a provider's accordion, but only when that model is degraded (<99% with attempts>0) so a long, healthy model list stays uncluttered. The math and its threshold constants live in lib/provider-rate.ts, unit-tested directly (vitest's node environment needs no component mount). Feature B: a provider's "refreshed ..." label (lib/format.ts's new refreshLabel) now reads "discovery off" when DiscoveryEnabled is false, "pending" when it is true but lastRefresh is still unset, and otherwise the unchanged relative-time text — replacing the misleading "refreshed never" a discovery-disabled provider showed before. Feature C: composables/useTabHash syncs App.vue's active tab to the URL hash (#providers/#usage/#charts/#targets, matching the existing TabsTrigger values) with no router dependency. On load it reads the current hash (falling back to "providers" for an absent or invalid one); on tab change it writes the hash back via history.replaceState (no scroll jump, no new history entry per click); a hashchange listener handles back/forward and hand-edited URLs. hashToTab/tabToHash are exported as pure functions so the mapping is unit-tested directly, independent of window/history/location — every DOM touch inside useTabHash itself is guarded behind one hasWindow check so the module still imports cleanly under vitest's node test environment. AuthGate never reads or writes the hash, so a tab named in the URL survives the unauthenticated -> authenticated transition unchanged. |
||
|
|
a25ebf0567 |
fix(webui): resilient dashboard polling (SF7)
useDashboardStore.refresh() used Promise.all across overview/usage/ targets: one route rejecting threw before any of the three state fields were assigned, so a single flaky GET /admin/api/targets blanked the already-working Providers and Usage views too. Switched to Promise.allSettled with per-section assignment: a fulfilled result updates its own field, a rejected one leaves the PREVIOUS value in place rather than nulling it. lastUpdated still advances on a partial success (some fresh data did arrive); error names which section(s) failed. A 401/403 on any section still defers to the auth store exactly as before (AdminApiError check unchanged). Added dashboard.spec.ts — the first Pinia store under vitest in this package (a store needs no DOM, so it fits the existing 'node' environment without pulling in jsdom); vite.config.ts's own test-scope comment updated to say so. |
||
|
|
7ad573b63e |
feat(webui): MCP & Agents admin tab
New 'MCP & Agents' tab (after Charts) fed by GET /admin/api/targets: two Card panels, each a sortable DataTable (reusing DataTable/ SortHeaderButton — no hand-rolled table markup) listing configured MCP servers and agents with name, url (muted, truncated with a title tooltip), access (Badge per allowed group, muted 'All groups' when unrestricted — the same idiom UsageView's own group Access block already uses), and req/min, req/day, req/month. - types/api.ts: AdminTargetCountersView/AdminTargetView/ AdminTargetsResponse matching admin.go's JSON exactly. - stores/dashboard.ts: targets fetched in the SAME Promise.all/5s poll as overview and usage — one store, one timer, not a third. - lib/target-columns.ts: shared ColumnDef set both DataTables use. - components/TargetsView.vue: SearchInput + useSearchQuery name filter (inline matching, mirroring ProvidersView's own single-field aliasMatches — not every filter needs a lib/*-search.ts module). |
||
|
|
b081055aaa |
fix(admin): wire-level access tests, MCP/agent UI, glob-safe chips
Review fix round on the group-access-display feature (Should-Fix + folded items): 1. TestAdminUsage_GroupAccessLists now asserts at the raw-JSON level, not just the typed adminUsageResponse struct: decodes rows into map[string]json.RawMessage and checks the four access-list keys are ENTIRELY ABSENT on user/total rows and an unrestricted group, PRESENT with exact values on a restricted group. A typed-struct decode alone cannot prove omitempty is doing its job -- a present-but-null field decodes into the same nil []string a genuinely absent key would. Verified by temporarily stripping the omitempty tags: the new assertions fail (four keys read back 'null'); the old struct-only assertions stayed green throughout, confirming the gap the review flagged. 2. UsageView.vue's Access block now renders MCP servers and Agents too, same Badge treatment as Providers, with muted 'All MCP servers'/ 'All agents' fallbacks -- GroupConfig carries all four lists (llmgateway.go), so the display now matches the payload instead of dropping two of the four fields silently. 3. A configured Models entry containing *, ?, or [ (path.Match's own special characters -- auth.go's matchesGlob) is a pattern, not a routable id: it now renders as a plain, non-copyable Badge (font-mono, secondary) instead of the copyable ModelChip, so a caller can no longer copy a glob pattern into a request's model field thinking it's a real id. Every Access label (Providers/Models/MCP servers/Agents) also gets a title attribute (one shared GLOB_HINT constant) making the glob-match semantics discoverable, since all four lists are glob-matched, not just Models. 4. groupSummary's providers/models/mcpServers/agents are now independent copies (cloneStringSlice), not aliases of the live group.* slices authorization reads on every request -- structurally prevents a future display-side sort/mutation from ever reordering or corrupting enforcement data. slices.Clone was tried first and reverted: it broke make yaegi-check (yaegi's stdlib symbol table does not resolve the generic slices package's type parameters under interpretation), so cloneStringSlice uses plain make/copy builtins instead -- verified yaegi-check passes with this version. 5. README.md's usage endpoint section: fixed the stale 'Overview and Usage views' reference (now 'Providers', matching the earlier tab rename) and added one line naming the new providers/models/ mcpServers/agents group-access fields, matching the section's existing terseness. 6. Renamed the 'unrestricted group carries empty/omitted lists' subtest to 'empty or omitted' -- the slash broke -run subtest path matching. 7. The Access block now uses the same dl/dt/dd markup as the sibling stat grid immediately below it, instead of a bespoke div/p structure -- one convention per panel. |
||
|
|
03bc67a5f0 |
feat(admin): expose group provider/model access
GET /admin/api/usage's group rows now carry the group's configured Providers/Models/MCPServers/Agents (GroupConfig, llmgateway.go), echoed exactly as configured -- never resolved/expanded to the full catalog server-side. Empty/unset stays omitted (omitempty), matching GroupConfig's own JSON convention and group.allowsX's empty-means-all semantics (auth.go): the dashboard can tell 'restricted to these N' apart from 'unrestricted' without the payload growing with catalog size. groupSummary (auth.go) gains providers/models/mcpServers/agents, carried through from authStore.snapshot unchanged. adminUsageEntryView (admin.go) gains the matching JSON fields, populated only for group entries -- a user or the total scope row never carries them, mirroring GroupName's own user-only convention. The Usage view's group accordion (webui/UsageView.vue) renders an Access block above the existing stat grid: Badge chips for explicit providers, ModelChip for explicit models (both shadcn/existing components, no hand-rolled chips), or muted 'All providers'/'All models' text when unrestricted. Display-only this round -- not wired into the user/group search filter. types/api.ts's AdminUsageEntryView mirrors the new optional fields. Tests: admin_test.go's new table-driven TestAdminUsage_GroupAccessLists covers a restricted group's exact lists, an unrestricted group's empty/omitted lists, and that a user/total row never carries them. |
||
|
|
bb4fa4420e |
refactor(webui): rename Overview tab to Providers
Mechanical rename: OverviewView.vue -> ProvidersView.vue (file, component import, tab label/value in App.vue), plus doc-comment/README references across webui/ that named the view by its old tab label. The backend GET /admin/api/overview endpoint, adminOverviewResponse type, and the frontend AdminOverviewResponse/dashboard.overview field all keep their original names unchanged (API surface) -- only the UI tab label and the component/file that renders it changed. No behavior or layout change to the view itself. |
||
|
|
5408b8e895 |
fix(webui): address auth-gate review findings
Explicit refocus. Add a watch(() => auth.error, ...) that calls the same focusKeyInput() helper onMounted already used. Previously, refocus-after-failure only worked through an undocumented chain (auth.submit() optimistically flips isAuthenticated, App.vue unmounts AuthGate, a 401 calls auth.reject(), isAuthenticated flips back, AuthGate remounts, onMounted refires). That chain still exists and still drives the shake retrigger; a comment above the Input now says so explicitly, so a future change to optimistic auth does not break either behavior silently. Security disclosure order. Move the three-item checklist above the form, so the claims sit in the reading path before the key field instead of below the submit button. Add a visually hidden `<h2 id="authgate-security-heading">` and tie the `<ul>` to it via aria-labelledby, giving screen-reader users a label the list did not have before. Wording and text-xs sizing are unchanged. Invalid-state wiring. Add :aria-invalid and :aria-describedby to the Input, pointing at a new id="authgate-error" on the Alert, closing the loop between the input and its own error message. Typography hierarchy. CardTitle grows to text-lg font-semibold; the "LLM Gateway" wordmark drops to a muted-foreground text-xs eyebrow, so the title now reads as the dominant line instead of losing to the wordmark's former text-sm font-semibold. Drop text-base from the Input's class list — Input.vue already sets text-base (overridden by its own md:text-sm at >=768px); the local copy did nothing but restate the default. h-10/pr-10 stay. |
||
|
|
49c9325c9f |
feat(webui): redesign admin key gate
Add a circular key-icon badge and a small "LLM Gateway" wordmark, so the screen reads as this product's door and not a generic prompt. Restructure the dense security paragraph into a short description plus a three-item muted checklist. Each claim keeps its original strength: the key stays in this tab's session storage, the browser never writes it to disk, and requests go only to this page's own /admin/api/* routes. Add a show/hide toggle for the key input. It reuses the ghost icon-button composition SearchInput.vue already uses for its clear button. Autofocus the input on mount. Disable submit while the trimmed input is empty. Add a brief shake on the key input when auth.error is set. The animation respects prefers-reduced-motion and lives in a scoped <style> block local to this file — the first one in webui/src/components, added because no Tailwind-core or tw-animate-css keyframe reads as "rejected". Extend the header in App.vue with a GitHub repository link, per an operator scope addition mid-task: an icon-only ghost button that opens github.com/lukaszraczylo/traefik-llmgateway in a new tab. The link sits in App.vue's header, above the gate/dashboard switch, so it renders on every screen including the auth gate. Installs @fortawesome/free-brands-svg-icons, pinned like the other fontawesome packages, for the faGithub icon. auth.submit() semantics, dashboard.refresh() timing, and the backend stay unchanged. |
||
|
|
9de2204752 |
chore(webui): drop dead clear() from useSearchQuery, add spec, doc composables
- useSearchQuery.ts: remove clear() — zero callers, since SearchInput's
clear button already clears by emitting update:modelValue('') through
v-model, no separate imperative clear needed.
- Add composables/useSearchQuery.spec.ts (3 cases: normalized trims,
normalized lowercases, hasQuery false for whitespace-only query).
- README.md Structure section: document src/composables/ (useThemeColors,
useSearchQuery) and mention the new shared SearchInput component
alongside the existing components/lib entries.
|
||
|
|
24809ed25a |
fix(webui): shared SearchInput/useSearchQuery, correct expand rationale
Address review of the user/group search filter (feat/user-search):
- Correct the "group name match does not auto-expand" rationale in
UsageView.vue and lib/search-expand.ts. The prior comment claimed
Overview has no provider-name search — false: OverviewView's
providerMatches checks each model's full ROUTABLE id
(`${provider}/${model}`, lib/format.ts's routableModelId), so a
provider-name query does match and auto-expand there. The actual
reason Usage withholds auto-expand from a name-only match: a group's
name is already legible on its collapsed trigger row, so expanding
every name match too would dump full member rosters for no reason;
a member-only match has no such shortcut and still needs the reveal.
Behavior is unchanged — only the (now accurate) justification.
- Extract components/SearchInput.vue and composables/useSearchQuery.ts.
OverviewView's model search, UsageView's user/group search, and
ChartsView's scope-picker search were three ~22-line copies of the
same icon+Input+clear-button markup, plus three copies of the
query/normalized/hasQuery/clear quartet. All three views now consume
the shared component/composable. SearchInput's clear button is a real
shadcn Button (variant="ghost", size="icon-xs" — 24x24px) instead of a
hand-rolled <button>, fixing a WCAG 2.2 SC 2.5.8 hit-target/
focus-visible gap once instead of three times.
- ChartsView's search input: w-48 (clipped the 22-char placeholder) to
w-56, matching the sibling SelectTrigger's width.
- UsageView.vue's visibleMembers now calls the exported, tested
matchingMembersOfGroup helper instead of re-implementing the same
filter inline with userMatches.
- Reworded the "shared with ChartsView.vue" claims on membersOfGroup
(usage-search.ts) and in UsageView.vue's visibleMembers doc comment:
ChartsView never calls membersOfGroup directly, only indirectly via
groupMatches -> matchingMembersOfGroup.
- UsageView's groups/users empty-state messages now follow Overview's
aliasEmptyMessage three-way pattern ("none configured" vs "no
groups/users match ..." vs "none"), distinguishing nothing-configured
from nothing-matches instead of collapsing both to the same text.
Gate unchanged: npm run build + npm test + vue-tsc, make admin-ui
(separate regen commit), go test ./... -count=1, gofmt/vet, make
yaegi-check.
|
||
|
|
d6b9879813 |
feat(webui): user and group search filter on usage and charts views
Add a "Filter users or groups" search input to UsageView, mirroring OverviewView's model search (same Input component, icon, and clear-button styling). Case-insensitive, trimmed substring match: - A user matches when its own name contains the query. - A group matches when its own name contains the query OR it has at least one matching member. Groups accordion shows only matching groups, sorted through the existing Groups toolbar unchanged. A group that matches only via a member auto-expands (using the same tested override semantics as Overview's provider search); a group matched by its own name is not force-expanded, since the match is already visible on its collapsed trigger row. The nested members table inside an expanded group shows every member when the group matched by name, and only the matching members when it matched via a member. The flat Users table filters to matching users, plus every member of a name-matched group. The Total card and ChartsView's chart rendering are untouched by the filter. ChartsView's scope picker (Select) gets the identical search input, filtering the user/group option list it offers. "Total" always stays in the list, and the currently selected scope is always kept in the list even if a new query would otherwise hide it, so an active chart is never torn down or re-labeled by narrowing the picker. Generalize lib/provider-expand.ts into lib/search-expand.ts: the same two-set (manuallyExpanded/manuallyCollapsed) auto-expand/override state machine now drives both Overview's providers and Usage's groups, keyed on generic string ids instead of provider names specifically. OverviewView.vue is updated to the renamed computeExpandedItems/toggleItemExpand exports; its own behavior is unchanged. The moved spec (search-expand.spec.ts) keeps all 9 original cases plus 2 new ones pinning the same semantics against non-provider (group-shaped) ids. Add lib/usage-search.ts with the shared user/group matching helpers (userMatches, groupNameMatches, membersOfGroup, matchingMembersOfGroup, groupMatches) so UsageView and ChartsView share one matching implementation, with its own vitest spec. UsageTable.vue gains an optional emptyMessage prop (threaded to DataTable) so UsageView can show a "no users match ..."/"no groups match ..." message under an active filter, matching Overview's own alias-search empty-state pattern. No backend/Go logic changes; filtering is client-side over the already-polled GET /admin/api/usage response. |
||
|
|
7f500ff803 | fix(webui): shared sort header, coherent group sorting, real expand-state tests | ||
|
|
60d66ca147 |
feat(webui): shadcn-vue accordion + sortable DataTable, full-width panel
Component-first admin-panel rework (operator directive), replacing hand-rolled patterns with the actual shadcn-vue library components rather than working around them. - Providers (OverviewView) and Groups (UsageView) now render as a real shadcn-vue Accordion: trigger is the summary line, content holds the detail (base URL, last refresh/error, model chips for providers; full stat grid + member users for groups) — the earlier hand-rolled second-<TableRow> toggle is retired. lib/provider-expand.ts's tested two-set expand-state module is unchanged; only the template now adapts it to Accordion's type="multiple" v-model via a writable computed instead of driving a plain v-if. - Usage view: expanding a group shows its member users' own usage rows — a client-side join on AdminUsageEntryView.groupName, which admin.go already sets per user (verified via admin_test.go's existing TestAdminUsage_MathAgainstSeededCounters; no backend change needed). - New DataTable.vue: the shadcn-vue DataTable pattern (@tanstack/vue-table, pinned to ^8.21.3 — the version whose API matches the documented pattern; npm's default "latest" resolves to v9, a breaking rewrite with a different API) factored into one reusable, generic component so every real table (users usage, aliases, a group's nested member list) gets clickable sortable headers with asc/desc indicators for free. usage-columns.ts holds the shared ColumnDef set; UsageTable.vue now delegates to DataTable. Groups themselves cannot be a literal <table> (Accordion wraps content in a <div>, illegal inside <tbody>), so their own sortability is driven by the same columns headlessly, surfaced as a small header-button toolbar above the Accordion. - Aliases table: alias id gets the ModelChip copy treatment (the alias name is the routable string); target stays plain text. - App.vue: dropped the max-w-6xl container — the panel now uses the full viewport width with responsive gutters (px-4 sm:px-6 lg:px-8). Table.vue already wraps every table in its own overflow-x-auto container, so a wide table still scrolls itself, never the page. shadcn-vue add accordion re-added @lucide/vue and required swapping its Chevron icons for FontAwesome (webui/README.md's own documented trap); components.json's iconLibrary/font keys stayed absent (verified main.css byte-identical after the add). Browser-verified against the real integration stack (real /admin over the Yaegi plugin, strict CSP incl. style-src 'self'): provider/group accordion expand, search auto-expand through the new Accordion adapter, sort toggles on aliases/users/groups-toolbar/nested-member tables, alias copy-to-clipboard, full-width at 1920px. Zero console errors (one unrelated MetaMask extension log). |
||
|
|
92e9ad0c1e | fix(webui): selectable chip fallback, precise css fixup docs | ||
|
|
b39d4e92c9 |
fix(webui): query-safe expand state, prefixed search, accessible row toggle
Review fix round (5 Important + 3 folded) against the provider-model
accordion (
|
||
|
|
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.
|
||
|
|
1aa2b43932 |
fix(webui): defuse scaffold config traps, tighten inline guards
Micro-fix from re-review: 1. webui/components.json: deleted the font/iconLibrary keys instead of picking a value. Re-review checked shadcn-vue's actual parse schema (node_modules/shadcn-vue/dist/schema/index.js's rawConfigSchema): both fields are z.string().optional(), not an enum - the five-choice enum lives only on the init CLI's --font/--icon-library flags, not on the components.json schema. The previous commit's "schema forbids a fix" rationale was wrong; corrected here. Verified empirically, not just by reading the schema: ran a real `npx shadcn-vue add badge -o -y` against components.json with both keys removed - src/assets/ main.css came back byte-for-byte unchanged (previously this exact class of call re-added a Google Fonts import and the .dark-class toggle). That same run did still re-add @lucide/vue to package.json's dependencies (some components' registry entries list an icon package regardless of iconLibrary) - reverted before committing, and webui/README.md's warning section rewritten to explain the omission (so nobody "helpfully" restores the keys), fix the schema claim, and note the narrower remaining package.json trap. 2. admin.go:27-28: adminCSP's doc comment now names all three set-sites (GET /admin, every GET /admin/assets/*, the three JSON routes) - previously only mentioned the page and JSON routes, dropped after the asset route gained the same header in the prior fix round. 3. Both inline-script guards (webui/generate.mjs's assertNoInlineAssets, admin_test.go's hasInlineScript) tightened from a bare \bsrc\s*= pattern to (^|\s)src\s*= - \b alone matches inside "data-src=" too (the "-" before "src" is a non-word character, so \b sits right before "src"), which would wrongly treat a data-src/nosrc attribute as the real src= that makes a <script> external. New Go test TestHasInlineScript_DataSrcIsNotExternal: <script data-src="x"> is still flagged as inline. No webui/src/ change this round - components.json isn't bundled, and generate.mjs's edit only touches its own build-time guard, not the Go-source-generation path. admin_assets_gen.go NOT regenerated (confirmed via a throwaway `node generate.mjs` + gofmt -w run: output is byte-identical to what's already committed). |
||
|
|
b98416d1e9 |
fix(webui): version display, CSP guards, config traps, bucket-aligned stacking
Review fix round (4 Important + 2 folded): 1. Version display: OverviewView.vue was missing the muted version line the old vanilla-JS page rendered (types/api.ts already declared overview.version) - added. 2. CSP invariant now guarded twice: webui/generate.mjs's new assertNoInlineAssets fails the build if dist/index.html contains an inline <script> (no src= attribute) or any <style> tag - the exact condition the tightened admin CSP (script-src/style-src 'self', no 'unsafe-inline') depends on. admin_test.go's new TestAdminIndexHTML_NoInlineAssets re-asserts the identical invariant server-side, against the decoded adminIndexHTML admin.go actually serves, so a regression is caught even if admin_assets_gen.go were regenerated with a modified copy of generate.mjs. 3. webui/components.json's iconLibrary:"lucide"/font:"inter" fields cannot be fixed in place - checked shadcn-vue's own schema (PRESET_ICON_LIBRARIES/PRESET_FONTS in the installed package): no "none"/fontawesome/system choice exists for either, and JSON (unlike the tsconfig.*.json files touched in the previous commit) has no comment syntax to warn inline. Documented instead: webui/README.md gained a loud warning section naming the exact regression (already hit twice this branch - a re-added Google Fonts import, a re-added lucide icon import) and the two-step fix to apply after any future shadcn-vue add/init call. 4. Real-CSP verification: the browser pass earlier in this branch ran under vite preview, which sends no CSP header at all. New integration/integration_test.go: TestAdminAssetCSP fetches GET /admin against the real Traefik+Yaegi pipeline, extracts the asset URL from its <script src="..."> tag, fetches that asset, and asserts both responses carry the exact adminCSP string byte-for-byte. admin.go's serveAdminAsset now also sets Content-Security-Policy (previously JSON/page routes only) so this holds for a direct navigation to an asset URL, not just as a same-origin sub-resource of GET /admin; admin_test.go's TestAdminAsset_Serving updated to match. 5. (folded) UsageChart.vue stacked the "tokens" tab's two series (tokin/tokout - two separate HTTP requests, not one atomic call) by positional index. A window rollover landing between the two requests could leave one series one bucket ahead, mis-stacking that refresh. Now aligns both series by their bucket key (a sorted union of every active series' buckets - the fixed-width, zero-padded bucket strings sort chronologically by construction), with a missing bucket in one series reading as 0 rather than shifting the whole series. 6. (folded) AuthGate.vue now triggers an immediate dashboard.refresh() on successful key submit, instead of waiting up to 5s for the next poll tick - the dashboard no longer sits blank right after auth. Rebuilt via make admin-ui; admin_assets_gen.go regenerated. |
||
|
|
655bbcbf2c |
feat(webui): vue admin panel source
Vue 3 + TypeScript + Pinia + Tailwind v4 + shadcn-vue + Chart.js admin panel source under webui/, built with Vite. Views: Overview (providers, redis/cache/retry status, model aliases), Usage (per-user/per-group/total tables with tokensIn/tokensOut/cost + limits), Charts (scope selector, req/tokens(stacked in-out)/cost metric tabs, 24h/30d/12mo window switcher, fed by GET /admin/api/usage/history). Pinia stores: auth (sessionStorage key gate, x-api-key header, 401/403 re-prompt), dashboard (5s poll), history (fetch-on-selection + 30s refresh). Design: deliberate neutral warm-neutral palette with a signature teal accent (no default Inter/purple-gradient/glassmorphism), real typographic hierarchy via the platform UI/mono font stacks (zero external assets - no Google Fonts), dark+light via prefers-color-scheme (no class toggle, no theme switcher). Charts: zero-based bars, honest axes, Okabe-Ito colorblind-safe series, tokens-in/out as a distinguishable stacked pair. vite.config.ts: base '/admin/' (matches admin.go's route layout) and build.modulePreload.polyfill:false (no inline bootstrap script, so the tightened admin CSP needs no 'unsafe-inline'). Code-split: Chart.js (ChartsView) loads lazily, not in the initial bundle. generate.mjs reads dist/ after `vite build` and writes ../admin_assets_gen.go (not committed by this change) - base64-encodes every asset, decoded once at package-var init; documented rationale in the script's own header comment. Hygiene: webui/go.mod walls webui/ off from the repo root's `go build`/ `vet`/`test ./...` (npm's node_modules bundles a stray .go file that would otherwise be swept in); tools/yaegi-check/main.go excludes webui/ from its GOPATH copy for the same reason. webui/ export-ignored (.gitattributes) from release source tarballs; webui/node_modules/ gitignored. tsconfig.app.json/tsconfig.node.json's JSONC comments dropped (TS itself is fine with either; the global pre-commit JSON validator is not) - no config values changed. |