A sidebar replaces the flat tabs with seven pages, and one filter bar
sets the time range, comparison period and scope for all of them.
- Home: spend against budget, forecast, request rate, error rate,
open breakers, top users and models, recent events and warnings.
- Spend: spend over time with comparison, budget burn-down, an
attribution drill-down from group to user to model, pricing health
and cost avoided by free models.
- Consumers: a directory with last seen and limit bars, an access
matrix, and a detail page per user.
- Models: the catalog with billing prices, fleet latency and error
rates, and provider health.
- Reliability: error, timeout, failover, 429 and 402 trends, events,
and latency per provider.
- Config: the redacted config and a change helper that emits YAML or
users.json snippets to paste into git.
- MCP & Agents: callers per target.
Page state lives in the URL hash, and old tab links map to the new
pages. The replaced views are removed. admin_assets_gen.go is
regenerated.
Claude-Session: https://claude.ai/code/session_01V5jYJNxpDFdJkyyExpADrv
The Models tab ranks by requests by default and asks the server for
per-model detail. A table under the chart lists requests, tokens in,
tokens out and cost for each model. A model with no recorded cost that
is marked free shows a "free" badge instead of $0. The CSV export
includes every column and the free flag. admin_assets_gen.go is
regenerated.
Claude-Session: https://claude.ai/code/session_01V5jYJNxpDFdJkyyExpADrv
The Usage tab shows a bar for each configured limit. It turns amber at
80% and red at 100%. The tab also shows rejected requests per day, a
projected month-end cost with budget headroom, and CSV export for users
and groups. Names link to Charts with the matching scope.
The Charts tab ranks models over the full 24h, 30d or 12mo window. A
provider filter is applied on the server, and the ranking can be
exported as CSV. A new Events tab lists recent failures, filtered by
kind and user. The selected tab, filters and scope live in the URL
hash. Polling stops while the page is hidden. Views show which replica
answered, and a banner lists configuration warnings.
CSV cells that start with a formula character are neutralised. Links
sit outside the accordion buttons, so both controls keep their
accessible names. admin_assets_gen.go is regenerated.
Claude-Session: https://claude.ai/code/session_01V5jYJNxpDFdJkyyExpADrv
Chart and ranking fetches carry a request id, and a superseded response
is dropped. Changing a selection clears the old data at once, so old
numbers never show under new labels or units. The auto-refresh tick
skips while a fetch is still in flight. "Loading..." shows only until
the first result for a selection arrives. Dashboard polls do not
overlap.
Users in several groups are counted and searched in every group. The
Models tab labels its windows as the current UTC bucket. Hour labels
are marked UTC. The Members column sorts numerically. Target access
shows "All groups" for null and "No groups" for []. The UI shows the
server's own error message. Cost axis ticks use the currency
formatter. npm run dev proxies /admin/api. Nine unused shadcn
components are removed.
admin_assets_gen.go is regenerated from the new build.
Claude-Session: https://claude.ai/code/session_01V5jYJNxpDFdJkyyExpADrv
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
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.
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.
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.
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.
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.
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.
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.
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).
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.
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.
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.