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.
This commit is contained in:
2026-08-22 00:00:48 +01:00
parent 56817b41d1
commit abbf5d0967
9 changed files with 271 additions and 61 deletions
+22 -7
View File
@@ -21,7 +21,7 @@ import {
CardTitle,
} from '@/components/ui/card'
import { useSearchQuery } from '@/composables/useSearchQuery'
import { formatAgo, formatTimestamp, refreshLabel, routableModelId } from '@/lib/format'
import { formatAgo, refreshDetailLabel, refreshLabel, routableModelId } from '@/lib/format'
import { isModelDegraded } from '@/lib/provider-rate'
import { type ExpandState, clearExpandOverrides, computeExpandedItems, toggleItemExpand } from '@/lib/search-expand'
import { useDashboardStore } from '@/stores/dashboard'
@@ -139,11 +139,20 @@ function visibleModels(p: AdminProviderView): string[] {
}
/** ZERO_MODEL_RATE is the fallback ProviderRateBadge reads for a model p.modelRates has no entry for — should not happen (admin.go's buildAdminOverview populates one entry per Models id, unconditionally), but a defensive fallback keeps a stale/mismatched client build from throwing rather than just under-reporting. */
const ZERO_MODEL_RATE: AdminModelRateView = { attemptsDay: 0, failuresDay: 0, attemptsMinute: 0, failuresMinute: 0 }
const ZERO_MODEL_RATE: AdminModelRateView = { attemptsDay: 0, failuresDay: 0 }
/** modelRateFor looks up one model's counters within p.modelRates (Feature A, v0.22), falling back to ZERO_MODEL_RATE. */
/**
* modelRateFor looks up one model's counters within p.modelRates (Feature
* A, v0.22), falling back to ZERO_MODEL_RATE. The optional-chain on
* modelRates itself (folded review minor, v0.22 review round), not just
* on the lookup, matches ZERO_MODEL_RATE's own doc comment: a stale
* client talking to an older server build (before this field existed at
* all) would otherwise throw on `.modelRates[model]` rather than fall
* back — the type says modelRates is always present, but a real response
* across a version skew is the one case that type cannot guarantee.
*/
function modelRateFor(p: AdminProviderView, model: string): AdminModelRateView {
return p.modelRates[model] ?? ZERO_MODEL_RATE
return p.modelRates?.[model] ?? ZERO_MODEL_RATE
}
/** modelIsDegraded gates the per-model rate badge (spec: "ONLY when that model is degraded") so a healthy or no-traffic model's ModelChip renders with no badge beside it at all. */
@@ -283,7 +292,7 @@ const aliasEmptyMessage = computed(() =>
</div>
<div>
<dt class="text-xs text-muted-foreground">Last refresh</dt>
<dd>{{ formatTimestamp(p.lastRefresh) }}</dd>
<dd>{{ refreshDetailLabel(p.discoveryEnabled, p.lastRefresh) }}</dd>
</div>
<div v-if="p.lastErr">
<dt class="text-xs text-muted-foreground">Last error</dt>
@@ -293,12 +302,18 @@ const aliasEmptyMessage = computed(() =>
<div v-if="visibleModels(p).length" class="flex flex-wrap gap-1.5">
<span v-for="m in visibleModels(p)" :key="m" class="inline-flex items-center gap-1">
<ModelChip :id="routableModelId(p.name, m)" />
<!--
attempts-minute/failures-minute deliberately omitted
(SHOULD-2, v0.22 review round): AdminModelRateView no
longer carries them at all, so leaving them unbound
(undefined) is what tells ProviderRateBadge to render
its day-window-only detail (dayRateTitle) instead of
claiming a live minute-window reading no data backs.
-->
<ProviderRateBadge
v-if="modelIsDegraded(p, m)"
:attempts-day="modelRateFor(p, m).attemptsDay"
:failures-day="modelRateFor(p, m).failuresDay"
:attempts-minute="modelRateFor(p, m).attemptsMinute"
:failures-minute="modelRateFor(p, m).failuresMinute"
/>
</span>
</div>