Commit Graph
7 Commits
Author SHA1 Message Date
lukaszraczylo eac0862d68 feat(admin,webui): metadata exposure
admin.go: adminProviderView gains ModelMeta (map[string]adminModelMetaView,
keyed by upstream model id) and adminAliasView gains its own ModelMeta —
both built via g.registry.resolveMetaFor/resolveMetaForAliasName
(feature v0.23). adminModelMetaView uses pointer fields
(*int/*float64), not plain zero values: a nil pointer means unknown, a
non-nil pointer (even one pointing at 0) means known, so a free model's
real zero cost never gets confused with an absent one over JSON.
resolveMetaForAliasName resolves an alias's metadata for this
unrestricted admin view via an internal &group{} (GroupConfig's own
documented empty-means-all semantics) — buildAdminOverview already
shows every provider/alias unconditionally, with no group filtering
anywhere else in its response either.

webui/src/types/api.ts: AdminModelMetaView mirrors the Go shape
(optional numbers, undefined means unknown); AdminProviderView.modelMeta
and AdminAliasView.modelMeta wire it through.

webui/src/lib/format.ts: formatContextWindow (binary-K "256k"-style),
formatModelCostHover ("in $X / out $Y per MTok", or "free"), and
their shared formatUsdPerMTok building block.

ModelChip.vue (operator scope refinement): every chip — the accordion's
model chips, the alias table's chips, a group's usage-detail chips —
gains optional contextTokens/inputPerMTokUsd/outputPerMTokUsd props.
When known, they append a resolved-metadata phrase to the existing
copy-state hover text, bound to BOTH title and aria-label (the same
mouse-vs-keyboard/screen-reader parity ProviderRateBadge.vue already
applies); when nothing is known, the hover is unchanged from before this
feature. An alias's chip carries its INHERITED metadata, matching the
alias-inheritance rule the backend already applies.

ProvidersView.vue: the accordion's model list gains a visible, muted
context-only chip ("256k") beside each ModelChip — cost stays
hover-only on the chip itself, per the operator's own "chips +
hover would be redundant on price" resolution.
2026-08-22 01:36:16 +01:00
lukaszraczylo abbf5d0967 fix(webui): accessible warn token, day-window model badges, review minors
Review round (v0.22) — MUST-1, SHOULD-2's frontend half, and every
folded minor that touched webui/.

MUST-1 (contrast): the degraded badge's amber previously reused
--color-chart-tokens-out, tuned for a chart mark, not body text —
measured ~2.22:1 against --card in light mode, a WCAG 1.4.3 failure on
exactly the "something's wrong" tier. A new semantic --status-warn token
pair (main.css, with a @theme inline mapping) replaces it: #a35c00 light
(~5.06:1), #f0ac1f dark (~8.97:1, already passing, redefined anyway so
every semantic-color token's dark value lives in one place).
ProviderRateBadge.vue's degraded tier now uses border-status-warn/40
text-status-warn.

SHOULD-2 (frontend half — see the backend commit for admin.go/limits.go):
AdminModelRateView drops attemptsMinute/failuresMinute (the backend no
longer sends them at all). ProviderRateBadge.vue's attempts-minute/
failures-minute props become optional; their absence (not a passed-
through 0) triggers a new dayRateTitle fallback (lib/provider-rate.ts) —
a real "0 attempts this minute" and "no minute data fetched at all" read
identically as 0 otherwise, which would misreport a model badge as having
live-minute data it never measured.

Folded minors:
- formatRatePercent floors instead of rounds — only a true 100% rate
  (zero failures) ever renders "100%"; 99.6% now shows "99%", not a
  round-tripped "100%" that would overstate it.
- providerSuccessRate NaN-guards both inputs (Number.isFinite).
- ProvidersView's modelRateFor uses `p.modelRates?.[model]` — a stale
  client against an older server build could otherwise throw rather than
  fall back, matching its own doc comment's stated intent.
- ProviderRateBadge's title is now also its aria-label — title alone is
  mouse-only; a keyboard/screen-reader user previously got none of the
  minute/day detail.
- The accordion's "Last refresh" DETAIL row (not just the trigger's
  compact text) now shares refreshLabel's three-state logic via a new
  refreshDetailLabel (lib/format.ts) — it previously called
  formatTimestamp directly, so a discovery-off provider's expanded row
  still said "never" beside a trigger that correctly said "discovery
  off".
- providerModelScopeID's Go doc comment (limits.go, backend commit)
  cross-references configNamePattern for why the "/" join is collision-
  safe even though a model id may itself contain "/".
- useTabHash.ts documents its two intentional URL-normalization quirks:
  case-sensitive hash matching, and an invalid/absent initial hash
  normalizing only in memory, not in the address bar, until the next tab
  change.
2026-08-22 00:00:48 +01:00
lukaszraczylo 2298249df5 feat(webui): rate badges, refresh labels, anchor links
Providers tab (Feature A): a new ProviderRateBadge component renders
each provider's day-window success rate (attempts-failures)/attempts —
always shown when attempts>0 (100% muted, <99% amber via the chart
palette's --color-chart-tokens-out token, <90% destructive, reusing the
Badge component's existing secondary/outline/destructive idioms rather
than inventing a new variant), a distinct "no traffic" badge when
attempts=0, and the live minute-window count in its title attribute.
The same component renders beside a ModelChip inside a provider's
accordion, but only when that model is degraded (<99% with attempts>0)
so a long, healthy model list stays uncluttered. The math and its
threshold constants live in lib/provider-rate.ts, unit-tested directly
(vitest's node environment needs no component mount).

Feature B: a provider's "refreshed ..." label (lib/format.ts's new
refreshLabel) now reads "discovery off" when DiscoveryEnabled is false,
"pending" when it is true but lastRefresh is still unset, and otherwise
the unchanged relative-time text — replacing the misleading "refreshed
never" a discovery-disabled provider showed before.

Feature C: composables/useTabHash syncs App.vue's active tab to the URL
hash (#providers/#usage/#charts/#targets, matching the existing
TabsTrigger values) with no router dependency. On load it reads the
current hash (falling back to "providers" for an absent or invalid one);
on tab change it writes the hash back via history.replaceState (no
scroll jump, no new history entry per click); a hashchange listener
handles back/forward and hand-edited URLs. hashToTab/tabToHash are
exported as pure functions so the mapping is unit-tested directly,
independent of window/history/location — every DOM touch inside
useTabHash itself is guarded behind one hasWindow check so the module
still imports cleanly under vitest's node test environment.
AuthGate never reads or writes the hash, so a tab named in the URL
survives the unauthenticated -> authenticated transition unchanged.
2026-08-21 23:23:21 +01:00
lukaszraczylo 7ad573b63e feat(webui): MCP & Agents admin tab
New 'MCP & Agents' tab (after Charts) fed by GET /admin/api/targets:
two Card panels, each a sortable DataTable (reusing DataTable/
SortHeaderButton — no hand-rolled table markup) listing configured
MCP servers and agents with name, url (muted, truncated with a title
tooltip), access (Badge per allowed group, muted 'All groups' when
unrestricted — the same idiom UsageView's own group Access block
already uses), and req/min, req/day, req/month.

- types/api.ts: AdminTargetCountersView/AdminTargetView/
  AdminTargetsResponse matching admin.go's JSON exactly.
- stores/dashboard.ts: targets fetched in the SAME Promise.all/5s
  poll as overview and usage — one store, one timer, not a third.
- lib/target-columns.ts: shared ColumnDef set both DataTables use.
- components/TargetsView.vue: SearchInput + useSearchQuery name
  filter (inline matching, mirroring ProvidersView's own single-field
  aliasMatches — not every filter needs a lib/*-search.ts module).
2026-08-21 18:27:04 +01:00
lukaszraczylo 03bc67a5f0 feat(admin): expose group provider/model access
GET /admin/api/usage's group rows now carry the group's configured
Providers/Models/MCPServers/Agents (GroupConfig, llmgateway.go), echoed
exactly as configured -- never resolved/expanded to the full catalog
server-side. Empty/unset stays omitted (omitempty), matching
GroupConfig's own JSON convention and group.allowsX's empty-means-all
semantics (auth.go): the dashboard can tell 'restricted to these N' apart
from 'unrestricted' without the payload growing with catalog size.

groupSummary (auth.go) gains providers/models/mcpServers/agents, carried
through from authStore.snapshot unchanged. adminUsageEntryView (admin.go)
gains the matching JSON fields, populated only for group entries -- a
user or the total scope row never carries them, mirroring GroupName's
own user-only convention.

The Usage view's group accordion (webui/UsageView.vue) renders an Access
block above the existing stat grid: Badge chips for explicit providers,
ModelChip for explicit models (both shadcn/existing components, no
hand-rolled chips), or muted 'All providers'/'All models' text when
unrestricted. Display-only this round -- not wired into the user/group
search filter. types/api.ts's AdminUsageEntryView mirrors the new
optional fields.

Tests: admin_test.go's new table-driven TestAdminUsage_GroupAccessLists
covers a restricted group's exact lists, an unrestricted group's
empty/omitted lists, and that a user/total row never carries them.
2026-08-21 13:21:58 +01:00
lukaszraczylo f9b289f1ea feat(webui): provider model accordion
Clicking a provider row in the dashboard expands to show its model
list, with a search filter and copy-to-clipboard on each model.

Backend (registry.go/admin.go): providerSnapshot gains models []string
- the sorted explicit union discovered id set, computed in the SAME
providerState.mu critical section as lastRefresh/lastErr (not via a
separate knownIDs() call, which takes the same non-reentrant lock and
would deadlock) so the three values are never assembled from two
separate locked passes a concurrent finishRefresh could interleave
between. adminProviderView exposes it as models (never nil - a real,
if empty, JSON array). New tests: overview shape asserts sorted
models per provider; a new TestModelRegistry_Snapshot_ModelsStaleWhileError
proves a failed refresh after an earlier success keeps the previous
discovered set (existing stale-while-error semantics, no special
handling added); TestAdmin_SecretRedaction unaffected (model ids are
not secrets, still passes as-is).

Frontend: the accordion is NOT shadcn-vue's Accordion or Collapsible -
both wrap trigger+content in one div, which cannot legally sit
between two tr elements inside a table/tbody. Installed
Collapsible via the CLI to check (verified components.json's now-
defused font/iconLibrary omission holds under a real install: main.css
stayed byte-for-byte unchanged) and inspected its source: reka-ui's
CollapsibleContent does support an "as" prop that could force it onto
a tr, but its animation-measurement code (getBoundingClientRect +
inline transition styles) is untested in that shape and not worth
taking on for an instant show/hide with no transition - removed the
installed files, kept a plain reactive Set<string> + v-if second
TableRow, which reuses Accordion's own visual language (rotating
chevron, keyboard-accessible trigger row: role=button, tabindex,
Enter/Space handlers) without misusing a component built for block
content.

Model chips (new ModelChip.vue) show the full ROUTABLE id -
"{providerName}/{modelId}" unconditionally, so a discovered id that
already contains a slash (uni/deepseek-v4-flash-0731 on provider
"real") renders as real/uni/deepseek-v4-flash-0731 and stays
copy-paste-valid - and copy that id to the clipboard on click
(lib/clipboard.ts: navigator.clipboard.writeText with a
select-the-text fallback when the Clipboard API is unavailable, never
the deprecated execCommand('copy')), with a brief checkmark
copied-state affordance. No CSP change: Clipboard access is a browser
permission, not a CSP directive.

Search input (OverviewView.vue) filters the providers table to
providers with a matching model (case-insensitive substring) and the
aliases table to aliases whose own name or target matches, auto-
expanding matching providers while a query is active and filtering
each expanded provider's own chip list down to just its matches (so a
72-model provider does not stay a 72-chip wall for a 1-model search).
Manual expand/collapse state is tracked separately from the search's
own auto-expand set, so clearing the query restores exactly what the
user had open by hand - nothing to explicitly "restore", since the
auto-expand set was never written into the manual one.

Integration: TestAdminDashboard's overview assertion extended
(adminProviderModels helper) to check the openai provider's models
array contains dynamic.yml.tmpl's explicit "gpt-mock" model - stays
API-level (asserts the JSON payload), not the rendered HTML, so the
prefixed-display-form request is left at the frontend only per scope.

Regenerated via make admin-ui; admin_assets_gen.go included.
2026-08-21 10:18:57 +01:00
lukaszraczylo 655bbcbf2c feat(webui): vue admin panel source
Vue 3 + TypeScript + Pinia + Tailwind v4 + shadcn-vue + Chart.js admin
panel source under webui/, built with Vite. Views: Overview (providers,
redis/cache/retry status, model aliases), Usage (per-user/per-group/total
tables with tokensIn/tokensOut/cost + limits), Charts (scope selector,
req/tokens(stacked in-out)/cost metric tabs, 24h/30d/12mo window
switcher, fed by GET /admin/api/usage/history). Pinia stores: auth
(sessionStorage key gate, x-api-key header, 401/403 re-prompt),
dashboard (5s poll), history (fetch-on-selection + 30s refresh).

Design: deliberate neutral warm-neutral palette with a signature teal
accent (no default Inter/purple-gradient/glassmorphism), real
typographic hierarchy via the platform UI/mono font stacks (zero
external assets - no Google Fonts), dark+light via prefers-color-scheme
(no class toggle, no theme switcher). Charts: zero-based bars, honest
axes, Okabe-Ito colorblind-safe series, tokens-in/out as a
distinguishable stacked pair.

vite.config.ts: base '/admin/' (matches admin.go's route layout) and
build.modulePreload.polyfill:false (no inline bootstrap script, so the
tightened admin CSP needs no 'unsafe-inline'). Code-split: Chart.js
(ChartsView) loads lazily, not in the initial bundle.

generate.mjs reads dist/ after `vite build` and writes
../admin_assets_gen.go (not committed by this change) - base64-encodes
every asset, decoded once at package-var init; documented rationale in
the script's own header comment.

Hygiene: webui/go.mod walls webui/ off from the repo root's `go build`/
`vet`/`test ./...` (npm's node_modules bundles a stray .go file that
would otherwise be swept in); tools/yaegi-check/main.go excludes webui/
from its GOPATH copy for the same reason. webui/ export-ignored
(.gitattributes) from release source tarballs; webui/node_modules/
gitignored. tsconfig.app.json/tsconfig.node.json's JSONC comments
dropped (TS itself is fine with either; the global pre-commit JSON
validator is not) - no config values changed.
2026-08-21 09:21:41 +01:00