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.
This commit is contained in:
2026-08-21 23:23:21 +01:00
parent bf23b41568
commit 2298249df5
10 changed files with 512 additions and 6 deletions
+92
View File
@@ -0,0 +1,92 @@
// Feature A (v0.22): per-provider / per-(provider,model) success-rate math
// for the Providers tab's badges (ProviderRateBadge.vue). Kept as pure
// functions in lib/, not inline in the component — the same "share once,
// import everywhere" discipline lib/format.ts and lib/search-expand.ts
// already apply, and it lets vitest's node-environment config (vite.config.
// ts's `test` block) exercise the threshold math directly, with no
// component mount required.
/**
* PROVIDER_RATE_AMBER_THRESHOLD is the day-window success-rate fraction
* (0-1) at and above which a provider or model reads as healthy ("muted"
* badge, spec: "100% muted"). Below it, the badge turns amber
* ("degraded", spec: "<99% amber") down to PROVIDER_RATE_DESTRUCTIVE_
* THRESHOLD.
*/
export const PROVIDER_RATE_AMBER_THRESHOLD = 0.99
/**
* PROVIDER_RATE_DESTRUCTIVE_THRESHOLD is the day-window success-rate
* fraction below which the badge turns destructive (spec: "<90%
* destructive") — a provider or model failing more than 1 in 10 attempts
* today.
*/
export const PROVIDER_RATE_DESTRUCTIVE_THRESHOLD = 0.9
/**
* ProviderRateStatus is the Providers tab's badge tier a success rate maps
* to: 'no-traffic' (attempts === 0 — never a misleadingly perfect 100%),
* 'healthy' (>= PROVIDER_RATE_AMBER_THRESHOLD, rendered muted), 'degraded'
* (below the amber threshold, at or above the destructive one), or
* 'severe' (below PROVIDER_RATE_DESTRUCTIVE_THRESHOLD).
*/
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%).
*/
export function providerSuccessRate(attempts: number, failures: number): number | null {
if (attempts <= 0) return null
return (attempts - failures) / attempts
}
/**
* providerRateStatus classifies a providerSuccessRate result into the
* Providers tab's badge tiers (see ProviderRateStatus). A null rate (no
* traffic) always maps to 'no-traffic', regardless of the threshold
* constants.
*/
export function providerRateStatus(rate: number | null): ProviderRateStatus {
if (rate === null) return 'no-traffic'
if (rate < PROVIDER_RATE_DESTRUCTIVE_THRESHOLD) return 'severe'
if (rate < PROVIDER_RATE_AMBER_THRESHOLD) return 'degraded'
return 'healthy'
}
/**
* isModelDegraded reports whether a model's OWN day-window rate should
* show a badge beside its ModelChip at all — the spec's "only when that
* model is degraded (<99% w/ attempts>0)" rule: a healthy or no-traffic
* model gets no badge, so the accordion is not cluttered with a badge
* beside every single model.
*/
export function isModelDegraded(attemptsDay: number, failuresDay: number): boolean {
const rate = providerSuccessRate(attemptsDay, failuresDay)
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%". */
export function formatRatePercent(rate: number): string {
return `${Math.round(rate * 100)}%`
}
/**
* 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.
*/
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}`
}