make admin-ui: vue-tsc -b && vite build, then webui/generate.mjs
against webui/dist/, gofmt -w'd. Reflects every webui/ change in this
branch: feature v0.23's metadata hover/chips and the compact-count
formatting addition.
Regenerate admin_assets_gen.go (webui/generate.mjs, gofmt -w) from the
webui/ build produced by the preceding review-round webui commit, so
admin.go's serveAdminPage/serveAdminAsset ship the MUST-1 contrast fix,
day-window per-model badges, the accordion "Last refresh" fix, and the
badge aria-labels.
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.
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.
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.
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).