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 ''
})
-
+
{{ label }}
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 {