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.
SHOULD-4 (v0.22 review round): tools/yaegi-check now drives a real chat
completion against a local httptest upstream, then GET
/admin/api/overview, asserting the resulting provider reports exactly
one attempt and zero failures — end to end under Yaegi, not merely
compiled: runUnified's attemptRecorder -> retryPolicy.do's context lookup
-> limiter.recordProviderAttempt's isTransient/isDeadlineExceeded
classification -> its fire-and-forget spawn -> buildAdminOverview's own
batched providerUsage read. This also proves the SHOULD-1 hand-rolled
errors.Unwrap walk (matchesSentinel/isDeadlineExceeded, limits.go) runs
correctly interpreted, which `go test` cannot: that class of bug is
exactly what this harness exists to catch.
Adapted from a reviewer-provided reference harness (a second
json.Unmarshal layering a mock-upstream provider and an admin-flagged
user on top of the plugin's own .traefik.yml testData) rather than
reinvented — reworked to poll GET /admin/api/overview briefly (SHOULD-5's
fire-and-forget write can lag a single immediate read) and to assert on
the decoded attemptsDay/failuresDay values instead of only printing them,
so a regression here actually fails the gate.
Review round (v0.22): 1 must-fix + 5 should-fix. This covers the three
backend items.
MUST-1's WCAG fix lives in the webui commit; this is the three provider/
admin-side SHOULD items.
SHOULD-1 (timeout blindness): a hung upstream previously read as 100%
healthy — isTransient (retry.go) deliberately excludes
context.DeadlineExceeded from what it retries (retrying past the
gateway's own deadline is pointless), and recordProviderAttempt reused
that same classification unforked, so a timed-out attempt recorded no
failure. Fixed narrowly in recordProviderAttempt only, per the ruling:
isTransient itself stays untouched and shared. A new isDeadlineExceeded
adds exactly one extra condition — a context.Canceled error (the client
walked away) still counts as an attempt only, from both functions alike.
The sentinel match (matchesSentinel) is a hand-rolled errors.Unwrap walk,
not errors.Is: providerHTTPError's own doc comment already documents that
errors.As panics under Yaegi checking whether an interpreted pointer type
implements error; errors.Is walks the identical internal machinery and
carries the same risk, so this sidesteps it with a bare
`err.(sentinelUnwrapper)` comma-ok assertion instead — plain interface
satisfaction, not reflection over a possibly-interpreted type.
SHOULD-2 (poll fan-out): a (provider, model) scope's minute-window
counters fed nothing but a badge's title text on the Providers tab, so a
dashboard with N configured models paid N wasted minute-window reads on
every 5s poll. providerCounterKeys/providerUsage now read a
kindProviderModel scope's day window only (2 keys, down from 4); a
kindProvider scope is unaffected (still 4 — one scope per provider, not
per model, so the read amplification never applied there).
adminModelRateView drops AttemptsMinute/FailuresMinute entirely, not just
unpopulated fields — the webui side (separate commit) falls back to a
day-window-only title for a per-model badge.
SHOULD-5 (TTFB): recordProviderAttempt's store write is telemetry-only —
nothing in the request path gates on it — so it now runs via a new
limiter.spawn dependency-injection field (mirrors nowFn/waitFn's existing
pattern), defaulting to a real goroutine in production and overridden to
run synchronously in every test that asserts on the resulting counter
immediately afterward. This removes the synchronous Redis round trip
this counter family added to the streaming TTFB path entirely. Loss on
process shutdown is accepted (telemetry only).
Regenerate admin_assets_gen.go (webui/generate.mjs, gofmt -w) from the
webui/ build produced by the preceding feat(webui) commit, so
admin.go's serveAdminPage/serveAdminAsset ship the Providers-tab rate
badges, refresh labels, and anchor-link syncing.
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.
Extend GET /admin/api/overview's existing providers payload rather than
adding a sibling endpoint: the route already returns one providers array
per request on the dashboard's 5s poll, is already admin-gated and
stats-excluded, and the new fields are one more batched limiter read
sharing that same request — a second endpoint would duplicate all three
properties for no benefit.
adminProviderView gains AttemptsDay/FailuresDay/AttemptsMinute/
FailuresMinute (the provider's own current-window counters) and
ModelRates (a per-model breakdown, keyed by upstream model id, via the
new adminModelRateView) — Feature A. buildAdminOverview builds every
provider's and every (provider, model) pair's limitScope in two passes
and reads them all through limiter.providerUsage in ONE storeGetMulti
call, then slices the flat result back by index.
adminProviderView also gains DiscoveryEnabled, mirroring a new
providerSnapshot.discoveryEnabled field (registry.go) read straight off
providerState — it is set once at construction and never mutated
afterward, so it needs neither providerState's mutex nor a place in its
existing snapshot() method's mutable-field return shape. Feature B: this
lets the webui tell "discovery is off" apart from "discovery is on but
has not refreshed yet", which previously both showed the same zero
lastRefresh as an identical "refreshed never".
Track per-provider and per-(provider,upstream-model) upstream-attempt
outcomes using the existing fixed-window counter machinery (limits.go),
following mcp_a2a.go's per-target counter pattern: kind-embedded keys
(kindProvider="prov", kindProviderModel="provmodel") keep a provider
scope collision-safe against a user/group/MCP-target/agent of the same
name.
limiter.recordProviderAttempt(provider, model, resp, err) increments an
"attempts" counter on every call, and a "failures" counter only for a
provider-fault outcome — reusing retry.go's own isTransient(resp, err)
classification exactly (transport/connection error, HTTP 429, or a 5xx),
not forking it. Both counters are written at minute and day granularity
only; provider health is a now-and-today question, not a billing one, so
there is no month window. limiter.providerUsage batches every scope's
read into one storeGetMulti round trip, mirroring targetUsage's own
single-batch discipline.
Wired at every chokepoint an upstream HTTP attempt resolves:
- providers.go's upstreamJSON/upstreamRawBytes path (chat, embeddings,
images, audio) via retry.go's retryPolicy.do, which now looks up an
optional attemptRecorder from ctx and invokes it once per retry
attempt (attempt-accounting: a request retried twice before
succeeding reports three attempts, not one).
- routes_unified.go's runUnified (chat/embeddings, streaming and
non-streaming alike — both funnel through the same upstreamJSON call).
- routes_media.go's three media handlers (images.generations,
audio.speech, audio.transcriptions).
- routes_passthrough.go's proxyUpstream (native provider passthrough) —
a single un-retried attempt, provider-level only: the upstream model
lives in the response body, read only after the attempt resolves.
The attemptRecorder is threaded via context (providers.go's
withAttemptRecorder/attemptRecorderFromContext), not a new function
parameter on every call site — proxyUpstream and retryPolicy.do are
both shared with paths that must NOT record provider accounting
(registry.go's discovery listModels calls, and the MCP/A2A target
proxy, mcp_a2a.go's handleTargetProxy), so scoping via context keeps
their signatures unchanged and those callers correctly opt out by
simply never wrapping their context.
Rewrote the federated POST /mcp bullets to document the review round 2
fix batch: per-backend timeouts (20s tools/list, 120s tools/call),
the bare-first-with-fallback session handshake and exactly what
triggers it (JSON-RPC error or HTTP 4xx, never a network failure or
5xx), the legacy HTTP+SSE-transport exclusion (URL path ending
'/sse') and why no handshake can fix it, the loud 'no MCP server
reachable' error when every attempted server fails, non-2xx/
empty-envelope rejection, the protocol-version allowlist, and the new
405 for a disallowed method on /mcp. Extended the admin-API GET
/admin/api/targets bullet, the total-scope accounting list, and the
rejected-before-quota bullet to include the federated endpoint.
Architecture diagram gains a Federated branch node (render-checked
with mmdc).
mocktool's 'tool' mode gains two federated-/mcp JSON-RPC endpoints:
'/' (lenient — every bare, session-less call succeeds, matching 8 of
11 real servers the live probe found) and '/strict' (session-required
— rejects a bare tools/list/tools/call with a JSON-RPC error until a
prior 'initialize' call's Mcp-Session-Id header accompanies it,
matching readitall/fetch's real behavior). A new 'toolstrict'
MCPServers entry in dynamic.yml.tmpl points at '/strict'.
TestFederatedMCP drives POST /mcp against real Traefik+Yaegi: a
federated tools/list must include both tool_echo (bare path) and
toolstrict_echo (reachable only via the plugin's own
bare-first-with-fallback handshake retry, mcp_federation.go's
mcpBackendCall), and a tools/call to toolstrict_echo must route
correctly with the prefix stripped. Closes the review gap ('only
goroutine in the package, zero real-interpreter coverage') the
go-test-only suite left: the handshake fallback, and the plugin's
goroutine fan-out in mcpFederatedToolsList, now both run for real
under Yaegi, not just against go test's in-process mocks.
Verified: make integration — ok github.com/lukaszraczylo/
traefik-llmgateway/integration 40.113s, exit 0, no FAIL/panic/skip in
the full run.
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.
Review round 2 fix batch for the federated /mcp endpoint, driven by a
live probe against all 11 production MCP servers behind this gateway
through the byte-pipe proxy (session-less tools/list): 8 answer bare,
readitall and fetch reject it and need a session handshake, playwright
is a legacy HTTP+SSE-transport server (400 Missing sessionId) that no
handshake fixes.
MF1: every backend call now runs under its own context.WithTimeout
derived from r.Context() — toolsListBackendTimeout (20s) per fan-out
call, toolsCallBackendTimeout (120s) for tools/call, named constants.
MF2: doBackendJSONRPC rejects any non-2xx upstream status as an error
(previously unchecked — a non-JSON-RPC error body only failed by
accident of not parsing). parseBackendJSONRPC additionally rejects a
validly-JSON body that carries neither "result" nor "error" — the
exact silent-failure shape that would otherwise decode into an
all-zero jsonrpcResponse and hand the client a bare
{"jsonrpc":"2.0","id":1}. mcpFederatedToolsCall carries a
defensive final guard against the same shape before relaying.
MF3: mcpBackendCall is now bare-first-with-fallback — attempt the
bare call; on a JSON-RPC error OR HTTP 4xx (never a network failure,
timeout, or 5xx), retry once via initialize -> capture Mcp-Session-Id
-> retry with the header -> best-effort DELETE close. A legacy
HTTP+SSE-transport server (URL path ending "/sse") is excluded from
federation entirely before ever reaching this — isLegacySSETransportURL,
folded into allowedMCPServerNames — since no handshake fixes a
different transport; it stays reachable via the per-server proxy. When
every attempted server fails, tools/list returns a JSON-RPC internal
error ("no MCP server reachable") instead of a silently-empty list.
SF6: defaultMCPProtocolVersion -> "2025-11-25"; a caller's own
protocolVersion is echoed back only from a small explicit allowlist
(knownMCPProtocolVersions), falling back to the default otherwise —
never echoes an arbitrary caller string as this gateway's own claimed
capability.
SF8: GET/DELETE/etc. on exactly /mcp is now 405 with Allow: POST,
checked before auth (llmgateway.go).
Folded minors: countTargetRequests batches per-target counter writes
for tools/list's whole fan-out into ONE storeIncrMulti call after
wg.Wait() (was one call per server); every "actually contacted" doc
comment now says "attempted" (attribution is attempt-, not
success-, accounting); lastSSEDataLine documents its no-cross-message-
id-correlation limitation; routes_unified.go's stale buildLimitScopes/
withTotalScope comments (which claimed handleAdminAPI calls
buildLimitScopes directly — it never has) are corrected; jsonrpcRequest
documents why its "jsonrpc" field is decoded but never validated.
Tests: ~30 new/updated cases covering per-backend timeout derivation
(via the shorter-of-two-context-deadlines technique, no real 20s/120s
waits), non-2xx rejection, the empty-envelope guard end-to-end,
handshake-trigger classification (table-driven: JSON-RPC error and
HTTP 4xx trigger, HTTP 500 and network failure do not), the full
handshake sequence (bare -> initialize -> retry -> close, asserted
call-by-call), handshake-gives-up-too, all-servers-failed, legacy-SSE
exclusion (never contacted, in both tools/list and tools/call),
protocol-version allowlist (all 4 versions + one rejected), 405/Allow,
and best-effort session-close failure never surfacing to the caller.
New POST /mcp: a single JSON-RPC 2.0 endpoint aggregating every MCP
server the caller's group can reach, for clients built against the
earlier pre-plugin gateway's one aggregated endpoint (pugbot's
mcpclient, agentkit) rather than this plugin's per-server
/mcp/{name}/... proxy.
Unlike the per-server proxy, this cannot be a byte-level reverse
proxy: aggregating N servers' tools/list results, or routing one
tools/call to the single right server, both require actually parsing
the JSON-RPC envelope. The gateway answers initialize/ping locally
and, for tools/list/tools/call, issues its own outbound JSON-RPC
call(s) to the relevant backend server(s) per incoming request — no
session state persisted across separate client requests, matching the
per-server proxy's own complete absence of gateway-side session
tracking. A backend may answer bare JSON or an SSE stream; both are
accepted (parseBackendJSONRPC).
- tools/list fans out concurrently to every allowed server and merges
results, prefixing each tool "<serverName>_<toolName>" — the
underscore convention the earlier agentgateway used and that
pugbot/agentkit already persist in their own tool-id records (e.g.
brave-search_brave_web_search, verified live). A failing/unreachable
server is skipped, not a whole-call failure.
- tools/call resolves the target by LONGEST matching
"<serverName>_" prefix against the caller's allowed servers
(disambiguates a server name that prefixes another), strips it,
forwards, and relays the result/error under the CLIENT's own id. An
unresolvable prefix (unconfigured, or authz-excluded) is a JSON-RPC
invalid-params error, not a 404.
- Reuses Feature B's countTargetRequest for per-target attribution —
every backend server actually contacted increments its own scope.
- notifications/* -> 202 no body; unknown method -> JSON-RPC
method-not-found; malformed body -> JSON-RPC parse error with
id:null (spec-required, not omitted).
No new limits config surface. Tests cover prefix resolution
(table-driven, incl. the ambiguous-prefix case), SSE/JSON backend
parsing, group-restricted aggregation (a denied server is never even
contacted), call routing/prefix-stripping, backend error relay,
unreachable-server handling, unknown-prefix errors, and per-target
counter attribution. README's MCP and A2A section documents the
federation contract and naming convention; the architecture diagram
and the total-scope/rejected-before-quota notes are updated to
include the new route.
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).
Add per-target request accounting for the MCP/A2A proxy path and a
new admin endpoint to surface it.
- limits.go: countTargetRequest increments one target's req counters
at min/hour/day/month in a single storeIncrMulti call — pure
accounting, no limits, mirroring account()'s own contract.
targetUsage batches the read side the same way currentUsage does
for user/group scopes.
- mcp_a2a.go: handleTargetProxy attributes every admitted proxied
request to its target's own scope, kind "mcp" or "agent"
(scopeKindAgent — deliberately not targetKindAgent's "a2a" routing
convention). kind is embedded in the counter key (windowKey), so a
target can never collide with a user/group/total scope of the same
id, whatever any of them are named.
- admin.go: new GET /admin/api/targets, admin-gated and
stats-excluded exactly like the other three /admin/api/* routes.
Access lists are computed via the SAME matchesGlob call
group.allowsMCP/allowsAgent themselves use, so the panel can never
disagree with enforcement; a target every group can reach collapses
to an omitted access list ("empty meaning all").
No new config surface: target scopes carry no configurable limits
this round.
Tests: TestTargetScopeKind (mapping), TestHandleTargetProxy_
PerTargetCounters_AttributedAndCollisionSafe (table-driven over
mcp/a2a, proves counters land on the right scope and never collide
with a same-named user), TestAdminTargets_* (gating tables extended,
shape/sorting/URL-sanitization/access-list/counter assertions).
buildLimitScopes previously omitted a user or group scope entirely
whenever that entity had no configured Limits, so a limit-less
user/group never accumulated ANY usage counters. Root-caused live:
the admin dashboard's total kept climbing while every per-user row
stayed zero until an operator added phantom, unreachable
requestsPerDay limits everywhere just to make counters build at all.
buildLimitScopes now always builds a user and a group scope. Limits
still gate enforcement only: evaluateScope already no-ops on a nil
Limits field, so a limit-less scope is counted but never violates.
Update stale doc comments coupling accounting to configured limits
(evaluateScope, the integration alice fixture, routes_media_test.go,
routes_unified_test.go, admin_test.go's admin1 assertion) and add:
- TestBuildLimitScopes_AlwaysBuildsBothScopes: table-driven over
every nil/non-nil user/group limits combination.
- TestAdminUsage_LimitlessUserAndGroup_AccumulateRealTraffic:
end-to-end regression proving a limit-less user and group
accumulate real request/token counters visible via
GET /admin/api/usage.
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.
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.
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.
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.
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.
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.
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.
Docker Desktop VirtioFS propagation of a host write to the guest's
view of the bind-mounted users.json is not bounded by the plugin's
own 5s reload throttle. Direct measurement (host write + polled
guest-side read, independent of the plugin) showed convergence times
from under 1s up to 18s, and a captured real test run took ~26s end
to end, exceeding the prior 20s deadline. Raise it to 60s, more than
2x the worst value observed, and document the measurement so a future
investigator does not have to re-derive it.
users_file.go was verified correct for this failure class: it
re-opens and re-reads the users file by path on every reload cycle,
never caches by file descriptor or inode, which is also the correct
pattern for a Kubernetes ConfigMap/Secret atomic symlink-swap update.
No production code changed.
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).
Review fix round (5 Important + 3 folded) against the provider-model
accordion (f9b289f):
1. Manual expand state corruption during an active search. Clicking a
row that was only auto-expanded by the search (never manually opened)
read the RAW manual flag as false and added it to manuallyExpanded -
invisible immediately (already showing via auto-expand) but wrongly
left expanded after the search cleared. Fixed with a two-set design in
a new, Vue-free module (webui/src/lib/provider-expand.ts):
manuallyExpanded (the real, cross-search manual state) plus
manuallyCollapsed (an override a click can set to suppress an
auto-expand while a query is active). toggleProviderExpand now flips
against the caller-computed EFFECTIVE current state, not the raw
manual flag. manuallyCollapsed clears whenever the query becomes empty
(watched, not just the clear button, so backspacing to empty resets it
too). Verified two ways: a throwaway Node assertion script (node
--experimental-strip-types, no test-runner dependency added)
exercises the exact review-described sequence against the plain
module; the identical sequence was then reproduced live in a real
browser and screenshotted at each step.
2. Search matched raw model ids while chips display and copy the
prefixed routable id - copying a chip and pasting it into search found
nothing. Fixed: matching is now against routableModelId(p.name, m) for
both the provider-level filter and the per-provider visible-model
filter - strictly more permissive than the bare id. Verified live:
searching the full "openai/gpt-4o-mini" string now matches.
3. role="button" on a <tr> broke table AT semantics - overriding a
<tr>'s row semantics to "button" strips it from the table's row/column
structure for a screen reader. Moved the interactive control to a real
<button type="button"> inside the first cell (the chevron+name span);
the row's own @click stays as a click-anywhere-in-row convenience, and
the button needs no click handler of its own - its native click bubbles
up to the row's listener, so exactly one place ever runs the toggle.
Added a visible focus style matching the existing shadcn-vue
focus-visible convention used elsewhere in this panel - verified live,
keyboard Tab produces a clearly visible ring. aria-controls is set only
while a provider is expanded, never pointing at an id that does not
yet exist (the content row stays v-if, lazy) - verified live via a
direct DOM check. Provider names are interpolated into that id
unescaped, not sanitized in JS - providers.go's configNamePattern
(charset [a-zA-Z0-9._-]) already rejects any character an HTML
id/attribute value would need escaped, before this endpoint can ever
see one.
4. DRY: providerState.knownIDs() and snapshot() independently rebuilt
the identical explicit-union-discovered merge-and-sort. Extracted
knownIDsLocked() (no locking of its own - caller must already hold
st.mu), consumed by both knownIDs() (takes the lock itself) and
snapshot() (already holds the lock, calls the lock-free variant
directly - a call to the locking knownIDs() from inside snapshot would
deadlock the non-reentrant mutex). snapshot()'s own doc comment
rewritten to describe the actual shared-helper shape instead of the
now-removed duplication it used to explain away.
5. (folded) Corrected a wrong claim in OverviewView.vue's own "why not
Accordion/Collapsible" doc comment: a <div> inside a <tbody> does NOT
get reparented by the browser's HTML parser ("foster parenting") in
this app - Vue renders entirely via DOM APIs, never by parsing an HTML
string, so the parser's foster-parenting algorithm (which only runs
building a DOM tree from a token stream) never executes here. What
actually breaks is CSS table layout: CSS2.1 17.2.1's anonymous-table-
object generation misbehaves around a block box sitting directly
inside a table-row-group box. The comment now says this.
6. (folded) Clipboard fallback hardening: ModelChip.vue's doc comment
now states the select-text fallback is the ONLY path a click ever
takes on a plain-HTTP admin deployment (Clipboard API needs a secure
context), not a rare edge case. A genuine copy failure now sets a
distinct "failed" state (destructive-colored icon, "Copy failed"
title, auto-resets) instead of bailing silently.
Regenerated via make admin-ui; admin_assets_gen.go = 850,968 bytes.
Gate: go build/gofmt/vet/fieldalignment clean, go test -count=1 ok
6.4s, go test -race ok 7.6s, golangci-lint clean (1 pre-existing
unrelated resp.go finding), make yaegi-check OK, npm run build clean,
make integration ok 45.0s (needed 3 attempts - TestUsersFileHotReload
is a pre-existing flake, now observed at least 3 times across this
body of work, unrelated to this diff; flagged for follow-up, not
fixed here).
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.
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).
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.
README.md: ## Admin rewritten for the Vue rebuild (webui/, new
GET /admin/assets/{hashedname} route, tightened CSP with no more
'unsafe-inline', usage-charts description) and the stats-exclusion
directive ("Admin traffic is never counted" replaces "Poll counts
against quota"). ## Development gains `make admin-ui` plus a note
that Node is needed only for that one target.
docs/superpowers/specs/2026-08-20-llmgateway-v02-design.md §4: an
amendment block documents the Vue rewrite (new route, tightened CSP,
Charts view) following this doc's established
controller-approved-amendment convention; the Accounting bullet is
amended for the stats-exclusion operator directive, with the original
wording struck through rather than deleted.
Operator directive (progress ledger, 2026-08-21): admin requests must
not touch req/min-req/day statistics. handleAdminAPI no longer calls
checkAndCount for GET /admin/api/overview or GET /admin/api/usage (it
already skipped it for GET /admin/api/usage/history) - admin traffic
must never appear in usage statistics, which measure real LLM traffic
only. identify()'s group return is now discarded (u, _, ok :=), since
buildLimitScopes is no longer built here.
Direct consequence, documented in the new doc comment: an admin's own
requestsPerMinute/requestsPerDay limit, if configured, is no longer
enforced against admin-route traffic either - accepted trade-off
(admin-gated, cheap reads; a self-inflicted, not shared, resource
cost).
admin_test.go: TestAdmin_RateLimitApplies (asserted a 2nd request
429s) inverted to TestAdmin_NeverRateLimitedAndCountersUntouched - 5x
requests to both overview and usage against an admin key pinned at
RequestsPerMinute:1, asserting every one is 200 and the admin's own
req/min counter stays exactly 0.
integration/integration_test.go: TestAdmin_Dashboard's admin-user
assertion inverted from requestsPerDay > 0 ("admin API calls count
too") to requestsPerDay == 0 ("admin routes never call
checkAndCount") - the admin key is used throughout the whole suite
exclusively to hit /admin/api/*, so it must show exactly 0 despite
issuing many requests by the time this test runs.
admin.go: GET /admin now serves the generated adminIndexHTML
(admin_assets_gen.go, webui/generate.mjs's output) instead of the old
adminPageHTML Go-string const (removed, ~340 lines, plus its now-dead
io import). New route GET /admin/assets/{hashedname}
(adminAssetsPathPrefix) serves the built app's content-hashed JS/CSS
from the generated adminAssets map - unauthenticated (same rationale as
GET /admin itself), Content-Type per asset, Cache-Control: public,
max-age=31536000, immutable, X-Content-Type-Options: nosniff; an
unknown name is a 404 in the standard OAI error envelope.
CSP tightens: script-src/style-src drop 'unsafe-inline' for 'self' (the
built app has no inline script or style of any kind - vite.config.ts's
build.modulePreload.polyfill:false sees to that), img-src gains 'self'
data:' for the shell's inlined-SVG favicon.
Makefile: new admin-ui target (npm ci && vite build, then
generate.mjs, then gofmt -w) - the only place Node is invoked; CI and
every other make target still need nothing beyond Go.
admin_test.go: TestAdminPage_CSPHeaderAndFetchURLs rewritten as a shell
test against the generated file (new CSP string, body references
/admin/assets/ and the <title>'s "LLM Gateway", no external
src=/href= reference - the favicon's xmlns attribute is excluded from
that check, it names an XML namespace URI, not a fetched resource);
TestAdmin_JSONSecurityHeaders' expected CSP updated; new
TestAdminAsset_Serving (Content-Type/nosniff/immutable-cache/byte-exact
body), TestAdminAsset_UnknownReturns404, and
TestAdminAssets_GeneratedFilePresent (non-empty index.html/assets, no
leftover {{/}}/generator-marker text leaked into the decoded HTML -
proves the base64 round-trip actually worked).
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.
1. README's fallback-charting story corrected: the 48h ceiling
(memoryStoreMaxTTL) only binds hour and day, not month. hour charts
its full default 48-bucket span; day shows only its most recent
~2-3 of the 35 possible buckets (its key still sits under the 48h
ceiling). month is different: enforceTTLFor's ~32-day floor wins
over the 48h ceiling there, so the CURRENT (in-progress) month's
bucket charts in full on the fallback — only past months are
unavailable, once their own key expired ~32 days after its own
creation. Aligns with limits.go's clampTTL/enforceTTLFor and the
+5d TestMemoryStore_IncrMulti_MonthCounterSurvivesPast48h... test
from the prior round.
2. limits.go's "5 call sites, all in admin_test.go" attribution
belonged to incrCounter, not incrBy — incrBy itself (memoryStore.
incrBy and redisStore.incrBy together) has 11 direct test call
sites. Fixed at both doc-comment sites (the counterStore interface
doc and memoryStore.incrBy's own doc); the no-production-caller
claim is unchanged and correct for both.
3. Appended the missing "Fix round 2" section to
.superpowers/sdd/usage-history-report.md, documenting the
enforcement-floor design (counterIncr.enforceTTL, enforceTTLFor,
newCounterIncr, clampTTL's ceiling-vs-floor precedence) that this
report's round-1 section never described. Corrected that same
section's stale "incrMulti's loop inherits the clamp for free,
since it calls incrBy" sentence — round 2 replaced that shape:
incrMulti now clamps its own entries via the shared applyIncr
primitive and no longer calls incrBy at all.
Gate: go test ./... -count=1, gofmt, vet — all green. Docs and
comments only, no behavior change.