From abbf5d0967205a957415343a7bcc6b7fca4ec4e0 Mon Sep 17 00:00:00 2001 From: Lukasz Raczylo Date: Sat, 22 Aug 2026 00:00:48 +0100 Subject: [PATCH] fix(webui): accessible warn token, day-window model badges, review minors MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- webui/src/assets/main.css | 23 +++++++ webui/src/components/ProviderRateBadge.vue | 50 ++++++++++----- webui/src/components/ProvidersView.vue | 29 ++++++--- webui/src/composables/useTabHash.ts | 14 +++++ webui/src/lib/format.spec.ts | 27 +++++++- webui/src/lib/format.ts | 63 ++++++++++++++----- webui/src/lib/provider-rate.spec.ts | 45 ++++++++++++-- webui/src/lib/provider-rate.ts | 71 ++++++++++++++++------ webui/src/types/api.ts | 10 +-- 9 files changed, 271 insertions(+), 61 deletions(-) diff --git a/webui/src/assets/main.css b/webui/src/assets/main.css index a8d26ac..222ca39 100644 --- a/webui/src/assets/main.css +++ b/webui/src/assets/main.css @@ -53,6 +53,7 @@ --color-chart-tokens-in: var(--chart-tokens-in); --color-chart-tokens-out: var(--chart-tokens-out); --color-chart-cost: var(--chart-cost); + --color-status-warn: var(--status-warn); --radius-sm: calc(var(--radius) - 4px); --radius-md: calc(var(--radius) - 2px); --radius-lg: var(--radius); @@ -86,6 +87,20 @@ --chart-tokens-in: #0072b2; --chart-tokens-out: #e69f00; --chart-cost: #d55e00; + + /* + * --status-warn: a semantic "degraded, not broken" indicator (the + * Providers tab's amber success-rate badge, ProviderRateBadge.vue) — + * deliberately its OWN token, not a reuse of --chart-tokens-out despite + * sharing that token's hue family. --chart-tokens-out is tuned to read + * against a chart's plot background at a small mark size, not as body + * text on --card; measured here at #e69f00-on-card it comes out to + * ~2.22:1, well under WCAG 1.4.3's 4.5:1 text-contrast minimum — a + * genuine accessibility bug on exactly the badge tier meant to say + * "something's wrong" (MUST-1, v0.22 review round). #a35c00 on the + * light --card measures ~5.06:1. + */ + --status-warn: #a35c00; } @media (prefers-color-scheme: dark) { @@ -116,6 +131,14 @@ --chart-tokens-in: #4098d7; --chart-tokens-out: #f0ac1f; --chart-cost: #e8763a; + + /* #f0ac1f on the dark --card measures ~8.97:1 — no separate dark + value was needed to clear WCAG 1.4.3 here, unlike light mode + (see --status-warn's own doc comment above), but the token is + still redefined per-mode rather than left to fall through, so a + reader auditing this block sees every semantic-color token's + dark value in one place. */ + --status-warn: #f0ac1f; } } diff --git a/webui/src/components/ProviderRateBadge.vue b/webui/src/components/ProviderRateBadge.vue index 7bf72b3..f5aee23 100644 --- a/webui/src/components/ProviderRateBadge.vue +++ b/webui/src/components/ProviderRateBadge.vue @@ -2,22 +2,30 @@ import { computed } from 'vue' import { Badge } from '@/components/ui/badge' -import { formatRatePercent, minuteRateTitle, providerRateStatus, providerSuccessRate } from '@/lib/provider-rate' +import { dayRateTitle, formatRatePercent, minuteRateTitle, providerRateStatus, providerSuccessRate } from '@/lib/provider-rate' /** * ProviderRateBadge is the Providers tab's success-rate badge (Feature A, * v0.22) — reused for both a provider's own row (ProvidersView.vue's * accordion trigger, always shown) and a single degraded model beside its * ModelChip (ProvidersView.vue's accordion content, shown only when - * isModelDegraded). Both call sites pass the identical four counters - * (admin.go's adminProviderView / adminModelRateView field shape), so one - * component covers both without a provider/model-specific prop. + * isModelDegraded). attemptsMinute/failuresMinute are OPTIONAL (SHOULD-2, + * v0.22 review round): the provider-row call site passes them (admin.go's + * adminProviderView carries minute counters); the per-model call site + * does not (adminModelRateView no longer does — a model scope's minute + * window is never fetched at all, see lib/provider-rate.ts's own + * dayRateTitle). Their absence, not a passed-through 0, is what tells + * this component to fall back to a day-window-only detail sentence — a + * real 0-attempts minute and "no minute data at all" are different + * things, and treating them as the identical "0" would silently claim a + * live "0 attempts in the last minute" reading a model badge never + * actually measured. */ const props = defineProps<{ attemptsDay: number failuresDay: number - attemptsMinute: number - failuresMinute: number + attemptsMinute?: number + failuresMinute?: number }>() const rate = computed(() => providerSuccessRate(props.attemptsDay, props.failuresDay)) @@ -25,28 +33,42 @@ const status = computed(() => providerRateStatus(rate.value)) const label = computed(() => (status.value === 'no-traffic' ? 'no traffic' : formatRatePercent(rate.value as number))) -/** title carries the minute-window "right now" detail (spec) regardless of tier — even a healthy badge's title shows the live minute count, not just the day-window percent the badge text itself displays. */ -const title = computed(() => minuteRateTitle(props.attemptsMinute, props.failuresMinute)) +/** + * detail carries the "right now" (provider) or "today" (model) counts — + * the spec's minute-window requirement where that data exists at all + * (minuteRateTitle), falling back to dayRateTitle when it does not. Used + * as BOTH the badge's title (mouse hover) and its aria-label (folded + * review minor, v0.22 review round: title alone is mouse-only — a + * keyboard or screen-reader user gets none of it without an aria-label + * carrying the same sentence). + */ +const detail = computed(() => + props.attemptsMinute === undefined || props.failuresMinute === undefined + ? dayRateTitle(props.attemptsDay, props.failuresDay) + : minuteRateTitle(props.attemptsMinute, props.failuresMinute), +) // Badge has no dedicated "amber"/"muted" variant (ui/badge/index.ts) — // severe reuses the built-in `destructive` variant exactly like every // other error state in this panel (e.g. ProvidersView's own lastErr // icon); healthy/no-traffic reuse `secondary`/`outline`, the panel's // existing "quiet" idioms (ProvidersView's provider-type badge is -// `variant="secondary"`); degraded is the one tier with no built-in -// variant, so it borrows the chart palette's amber token -// (--color-chart-tokens-out, main.css) the same way the Redis/Cache/Retry -// status cards above already borrow --color-chart-requests for "ok". +// `variant="secondary"`); degraded borrows the semantic --status-warn +// token (main.css — MUST-1, v0.22 review round: --chart-tokens-out, this +// component's original choice, measures ~2.22:1 against --card in light +// mode, a WCAG 1.4.3 text-contrast failure on exactly the "something's +// wrong" tier; --status-warn is tuned for body-text contrast instead, +// ~5.06:1 light / ~8.97:1 dark). const variant = computed(() => (status.value === 'severe' ? 'destructive' : status.value === 'healthy' ? 'secondary' : 'outline')) const extraClass = computed(() => { - if (status.value === 'degraded') return 'border-chart-tokens-out/40 text-chart-tokens-out' + if (status.value === 'degraded') return 'border-status-warn/40 text-status-warn' if (status.value === 'no-traffic') return 'text-muted-foreground' return '' }) diff --git a/webui/src/components/ProvidersView.vue b/webui/src/components/ProvidersView.vue index 49143bb..63dcb0e 100644 --- a/webui/src/components/ProvidersView.vue +++ b/webui/src/components/ProvidersView.vue @@ -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(() =>
Last refresh
-
{{ formatTimestamp(p.lastRefresh) }}
+
{{ refreshDetailLabel(p.discoveryEnabled, p.lastRefresh) }}
Last error
@@ -293,12 +302,18 @@ const aliasEmptyMessage = computed(() =>
+
diff --git a/webui/src/composables/useTabHash.ts b/webui/src/composables/useTabHash.ts index ef7230a..b329c00 100644 --- a/webui/src/composables/useTabHash.ts +++ b/webui/src/composables/useTabHash.ts @@ -18,6 +18,20 @@ import { onMounted, onUnmounted, ref, watch } from 'vue' * validTabs. Matching is a plain string comparison: a tab value is * already a fixed lowercase identifier (App.vue's TabsTrigger value= * strings), never re-cased anywhere in this pipeline. + * + * Two intentional URL-normalization quirks (folded review minors, v0.22 + * review round), both worth naming rather than leaving implicit: + * + * 1. Case sensitivity: "#Providers" does NOT match "providers" — it falls + * back to defaultTab like any other unrecognized hash, rather than + * being normalized case-insensitively. A URL a person typed or edited + * by hand with different casing is treated as invalid, not corrected. + * 2. An invalid or absent hash is normalized only in memory (this + * function's own return value), never in the address bar itself: + * useTabHash's initial read below never calls history.replaceState — + * only a LATER tab change does (see its own doc comment) — so loading + * "#bogus" shows the Providers tab while the address bar still reads + * "#bogus" until the reader switches tabs at least once. */ export function hashToTab(hash: string, validTabs: readonly T[], defaultTab: T): T { const raw = hash.startsWith('#') ? hash.slice(1) : hash diff --git a/webui/src/lib/format.spec.ts b/webui/src/lib/format.spec.ts index b6261c2..b064dcc 100644 --- a/webui/src/lib/format.spec.ts +++ b/webui/src/lib/format.spec.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest' -import { refreshLabel } from './format' +import { refreshDetailLabel, refreshLabel } from './format' const ZERO_TIME = '0001-01-01T00:00:00Z' @@ -24,3 +24,28 @@ describe('refreshLabel', () => { expect(refreshLabel(true, oneMinuteAgo)).toMatch(/^refreshed \(\d+s ago\)$/) }) }) + +// TestAdminOverview_ProviderLastErrAfterFailedRefresh's webui counterpart: +// the accordion DETAIL row (folded review minor, v0.22 review round) must +// share refreshLabel's own three-state logic instead of calling +// formatTimestamp directly — the bug this closes was a discovery-off +// provider's expanded row still reading the raw "never" right next to a +// trigger row that correctly said "discovery off". +describe('refreshDetailLabel', () => { + it('reads "discovery off" when discovery is disabled, regardless of lastRefresh', () => { + expect(refreshDetailLabel(false, ZERO_TIME)).toBe('discovery off') + expect(refreshDetailLabel(false, undefined)).toBe('discovery off') + }) + + it('reads "pending" when discovery is enabled but lastRefresh is still the unset sentinel', () => { + expect(refreshDetailLabel(true, ZERO_TIME)).toBe('pending') + }) + + it('reads the full formatTimestamp date/time once discovery has completed a refresh, not a relative "(Ns ago)"', () => { + const iso = new Date(2026, 7, 21, 12, 0, 0).toISOString() + const got = refreshDetailLabel(true, iso) + expect(got).not.toMatch(/ago/) + expect(got).not.toBe('pending') + expect(got).not.toBe('discovery off') + }) +}) diff --git a/webui/src/lib/format.ts b/webui/src/lib/format.ts index ac69439..0ba518b 100644 --- a/webui/src/lib/format.ts +++ b/webui/src/lib/format.ts @@ -40,22 +40,57 @@ export function formatTimestamp(iso: string | undefined): string { } /** - * refreshLabel renders a provider's discovery/refresh status as the - * Providers tab's own three-state label (Feature B, v0.22 — last-refresh - * label honesty): discovery disabled reads "discovery off" regardless of - * lastRefresh (a disabled provider's lastRefresh is always the unset - * sentinel, registry.go's own maybeRefresh/warmFill never call - * finishRefresh for it — but this checks discoveryEnabled first anyway, - * not lastRefresh's value, so it stays correct even if that invariant - * ever changes); discovery enabled but never yet refreshed reads - * "pending"; otherwise it is the existing "refreshed (Ns ago)" relative- - * time text, unchanged. + * refreshState is the Providers tab's three-state refresh classification + * (Feature B, v0.22 — last-refresh label honesty), shared by refreshLabel + * (the accordion trigger's compact text) and refreshDetailLabel (the + * accordion content's "Last refresh" detail row — folded review minor, + * v0.22 review round: that row still read raw formatTimestamp before this + * existed, so a discovery-off provider's expanded detail said "never" + * right next to a trigger that correctly said "discovery off"): + * discovery disabled reads 'off' regardless of lastRefresh (a disabled + * provider's lastRefresh is always the unset sentinel, registry.go's own + * maybeRefresh/warmFill never call finishRefresh for it — but this checks + * discoveryEnabled first anyway, not lastRefresh's value, so it stays + * correct even if that invariant ever changes); discovery enabled but + * never yet refreshed reads 'pending'; otherwise 'refreshed'. + */ +function refreshState(discoveryEnabled: boolean, lastRefresh: string | undefined): 'off' | 'pending' | 'refreshed' { + if (!discoveryEnabled) return 'off' + return formatAgo(lastRefresh) ? 'refreshed' : 'pending' +} + +/** + * refreshLabel renders refreshState as the Providers tab's compact + * trigger-row text: "discovery off", "pending", or the existing + * "refreshed (Ns ago)" relative-time text. */ export function refreshLabel(discoveryEnabled: boolean, lastRefresh: string | undefined): string { - if (!discoveryEnabled) return 'discovery off' - const ago = formatAgo(lastRefresh) - if (!ago) return 'pending' - return `refreshed${ago}` + switch (refreshState(discoveryEnabled, lastRefresh)) { + case 'off': + return 'discovery off' + case 'pending': + return 'pending' + case 'refreshed': + return `refreshed${formatAgo(lastRefresh)}` + } +} + +/** + * refreshDetailLabel renders refreshState as the Providers tab's + * accordion-content "Last refresh" detail row: "discovery off", "pending", + * or the full formatTimestamp date/time (this row's own pre-existing + * convention — refreshLabel's relative "(Ns ago)" belongs on the compact + * trigger row only). + */ +export function refreshDetailLabel(discoveryEnabled: boolean, lastRefresh: string | undefined): string { + switch (refreshState(discoveryEnabled, lastRefresh)) { + case 'off': + return 'discovery off' + case 'pending': + return 'pending' + case 'refreshed': + return formatTimestamp(lastRefresh) + } } /** formatLimits renders a LimitsConfig as a short comma-joined summary, or "none" when unset. */ diff --git a/webui/src/lib/provider-rate.spec.ts b/webui/src/lib/provider-rate.spec.ts index 42e6616..bb639fe 100644 --- a/webui/src/lib/provider-rate.spec.ts +++ b/webui/src/lib/provider-rate.spec.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from 'vitest' import { + dayRateTitle, formatRatePercent, isModelDegraded, minuteRateTitle, @@ -30,6 +31,15 @@ describe('providerSuccessRate', () => { it('returns null for a negative attempts count (defensive edge case)', () => { expect(providerSuccessRate(-1, 0)).toBeNull() }) + + it('returns null for a non-finite attempts count (folded review minor: NaN-guard the badge props)', () => { + expect(providerSuccessRate(Number.NaN, 0)).toBeNull() + expect(providerSuccessRate(Number.POSITIVE_INFINITY, 0)).toBeNull() + }) + + it('treats a non-finite failures count as 0 rather than propagating NaN', () => { + expect(providerSuccessRate(10, Number.NaN)).toBe(1) + }) }) describe('providerRateStatus', () => { @@ -81,10 +91,19 @@ describe('isModelDegraded', () => { }) describe('formatRatePercent', () => { - it('rounds to the nearest whole percent', () => { + it('floors rather than rounds, so only a true 100% rate ever shows 100%', () => { expect(formatRatePercent(0.994)).toBe('99%') - expect(formatRatePercent(0.995)).toBe('100%') + // Round would have bumped this to "100%" (99.5 rounds up) — floor + // correctly keeps it "99%": the rate is not actually a perfect 100%. + expect(formatRatePercent(0.995)).toBe('99%') + expect(formatRatePercent(0.999)).toBe('99%') + }) + + it('shows 100% only for an exact rate of 1', () => { expect(formatRatePercent(1)).toBe('100%') + }) + + it('floors 0 to "0%"', () => { expect(formatRatePercent(0)).toBe('0%') }) }) @@ -98,11 +117,29 @@ describe('minuteRateTitle', () => { expect(minuteRateTitle(1, 1)).toBe('1 attempt, 1 failure in the last minute (0%)') }) - it('pluralizes attempts independently of a singular failure count, with the rounded percent', () => { - expect(minuteRateTitle(12, 1)).toBe('12 attempts, 1 failure in the last minute (92%)') + it('pluralizes attempts independently of a singular failure count, with the floored percent', () => { + expect(minuteRateTitle(12, 1)).toBe('12 attempts, 1 failure in the last minute (91%)') }) it('omits failures from wording only via the count itself (0 failures still says "0 failures")', () => { expect(minuteRateTitle(5, 0)).toBe('5 attempts, 0 failures in the last minute (100%)') }) }) + +describe('dayRateTitle', () => { + it('reports no traffic distinctly when attemptsDay is 0 (per-model badge fallback, SHOULD-2)', () => { + expect(dayRateTitle(0, 0)).toBe('no traffic today') + }) + + it('uses singular attempt/failure wording for a count of exactly 1', () => { + expect(dayRateTitle(1, 1)).toBe('1 attempt, 1 failure today (0%)') + }) + + it('pluralizes independently, with the floored percent', () => { + expect(dayRateTitle(12, 1)).toBe('12 attempts, 1 failure today (91%)') + }) + + it('says "today" rather than "in the last minute" — the only wording difference from minuteRateTitle', () => { + expect(dayRateTitle(5, 0)).toBe('5 attempts, 0 failures today (100%)') + }) +}) diff --git a/webui/src/lib/provider-rate.ts b/webui/src/lib/provider-rate.ts index 0db8b8a..21729e7 100644 --- a/webui/src/lib/provider-rate.ts +++ b/webui/src/lib/provider-rate.ts @@ -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}` } diff --git a/webui/src/types/api.ts b/webui/src/types/api.ts index 978018c..8fb37cf 100644 --- a/webui/src/types/api.ts +++ b/webui/src/types/api.ts @@ -15,14 +15,16 @@ export interface LimitsConfig { /** * One upstream model's current-window attempt/failure counters within its * provider (admin.go: adminModelRateView) — Feature A, v0.22 per-model - * success-rate breakdown. Field shape matches AdminProviderView's own - * attempts/failures counters below, just scoped to one model. + * success-rate breakdown. Day window only, no attemptsMinute/ + * failuresMinute (SHOULD-2, v0.22 review round): a dashboard with N + * configured models was paying N wasted minute-window store reads on + * every 5s poll for a figure that only ever fed a badge's title text, + * never its own displayed tier. See lib/provider-rate.ts's dayRateTitle + * for the fallback title text a per-model badge renders instead. */ export interface AdminModelRateView { attemptsDay: number failuresDay: number - attemptsMinute: number - failuresMinute: number } export interface AdminProviderView {