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
+54 -17
View File
@@ -34,14 +34,19 @@ export type ProviderRateStatus = 'no-traffic' | 'healthy' | 'degraded' | 'severe
/**
* providerSuccessRate computes the (attempts-failures)/attempts success
* rate as a 0-1 fraction, or null when attempts is 0 or negative — the
* "no traffic yet" case a caller must render as its own state, never as a
* fraction (a 0/0 division would otherwise read as NaN, or worse, as a
* misleading 100%).
* rate as a 0-1 fraction, or null when attempts is 0, negative, or not a
* finite number — the "no traffic yet" case a caller must render as its
* own state, never as a fraction (a 0/0 division would otherwise read as
* NaN, or worse, as a misleading 100%). failures is defensively treated
* as 0 when it is not a finite number (folded review minor, v0.22 review
* round): every real caller sources both counters from the same
* admin.go response, so this only guards a version-skew or malformed-
* response case, not a path normal operation reaches.
*/
export function providerSuccessRate(attempts: number, failures: number): number | null {
if (attempts <= 0) return null
return (attempts - failures) / attempts
if (!Number.isFinite(attempts) || attempts <= 0) return null
const safeFailures = Number.isFinite(failures) ? failures : 0
return (attempts - safeFailures) / attempts
}
/**
@@ -69,24 +74,56 @@ export function isModelDegraded(attemptsDay: number, failuresDay: number): boole
return rate !== null && rate < PROVIDER_RATE_AMBER_THRESHOLD
}
/** formatRatePercent renders a 0-1 fraction as a rounded whole-percent string, e.g. 0.994 -> "99%", 1 -> "100%". */
/**
* formatRatePercent renders a 0-1 fraction as a whole-percent string,
* FLOORED rather than rounded (folded review minor, v0.22 review round):
* only a true 100% rate — zero failures — ever renders "100%"; a rate
* like 0.996 (unrounded, sub-1% failure) renders "99%", not a
* round-tripped "100%" that would read as flawless when it is not quite.
* A rate at or above 1 (the only way to reach exactly 100%, given
* providerSuccessRate's own (attempts-failures)/attempts shape) is
* special-cased rather than left to Math.floor's own rounding, purely for
* clarity at the boundary.
*/
export function formatRatePercent(rate: number): string {
return `${Math.round(rate * 100)}%`
if (rate >= 1) return '100%'
return `${Math.floor(rate * 100)}%`
}
/** countWord returns singular when n is exactly 1, plural otherwise — shared by minuteRateTitle and dayRateTitle so their wording never drifts apart. */
function countWord(n: number, singular: string, plural: string): string {
return n === 1 ? singular : plural
}
/**
* minuteRateTitle builds the badge's title attribute — the "right now"
* minute-window detail the spec asks for beside the day-window badge text
* itself, e.g. "12 attempts, 1 failure in the last minute (92%)". Singular/
* plural wording follows the actual count, and a zero-attempt minute reads
* as its own sentence rather than a "0%" that would misread as a total
* outage.
* minuteRateTitle builds the provider-level badge's title/aria-label —
* the "right now" minute-window detail the spec asks for beside the
* day-window badge text itself, e.g. "12 attempts, 1 failure in the last
* minute (92%)". Singular/plural wording follows the actual count, and a
* zero-attempt minute reads as its own sentence rather than a "0%" that
* would misread as a total outage. Provider-level only — a per-model
* badge no longer has minute-window counters to build this from at all
* (SHOULD-2, v0.22 review round: see dayRateTitle, its own fallback).
*/
export function minuteRateTitle(attemptsMinute: number, failuresMinute: number): string {
if (attemptsMinute <= 0) return 'no traffic in the last minute'
const rate = providerSuccessRate(attemptsMinute, failuresMinute)
const pct = rate === null ? '' : ` (${formatRatePercent(rate)})`
const attemptWord = attemptsMinute === 1 ? 'attempt' : 'attempts'
const failureWord = failuresMinute === 1 ? 'failure' : 'failures'
return `${attemptsMinute} ${attemptWord}, ${failuresMinute} ${failureWord} in the last minute${pct}`
return `${attemptsMinute} ${countWord(attemptsMinute, 'attempt', 'attempts')}, ${failuresMinute} ${countWord(failuresMinute, 'failure', 'failures')} in the last minute${pct}`
}
/**
* dayRateTitle is minuteRateTitle's day-window equivalent — the per-model
* badge's title/aria-label (SHOULD-2, v0.22 review round): a model
* scope's minute-window counters are no longer fetched anywhere
* (admin.go's buildAdminOverview, limits.go's providerCounterKeys), so a
* per-model badge cannot build minuteRateTitle's "in the last minute"
* sentence — this is the "today" equivalent, built from the same day
* counters the badge's own visible percent already uses.
*/
export function dayRateTitle(attemptsDay: number, failuresDay: number): string {
if (attemptsDay <= 0) return 'no traffic today'
const rate = providerSuccessRate(attemptsDay, failuresDay)
const pct = rate === null ? '' : ` (${formatRatePercent(rate)})`
return `${attemptsDay} ${countWord(attemptsDay, 'attempt', 'attempts')}, ${failuresDay} ${countWord(failuresDay, 'failure', 'failures')} today${pct}`
}