mirror of
https://github.com/lukaszraczylo/traefik-llmgateway.git
synced 2026-10-16 05:37:28 +00:00
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:
@@ -0,0 +1,26 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { refreshLabel } from './format'
|
||||
|
||||
const ZERO_TIME = '0001-01-01T00:00:00Z'
|
||||
|
||||
describe('refreshLabel', () => {
|
||||
it('reads "discovery off" when discovery is disabled, regardless of lastRefresh', () => {
|
||||
expect(refreshLabel(false, ZERO_TIME)).toBe('discovery off')
|
||||
expect(refreshLabel(false, new Date().toISOString())).toBe('discovery off')
|
||||
expect(refreshLabel(false, undefined)).toBe('discovery off')
|
||||
})
|
||||
|
||||
it('reads "pending" when discovery is enabled but lastRefresh is still the unset sentinel', () => {
|
||||
expect(refreshLabel(true, ZERO_TIME)).toBe('pending')
|
||||
})
|
||||
|
||||
it('reads "pending" when discovery is enabled and lastRefresh is undefined (edge case)', () => {
|
||||
expect(refreshLabel(true, undefined)).toBe('pending')
|
||||
})
|
||||
|
||||
it('reads the relative-time "refreshed (Ns ago)" text once discovery has completed a refresh', () => {
|
||||
const oneMinuteAgo = new Date(Date.now() - 60_000).toISOString()
|
||||
expect(refreshLabel(true, oneMinuteAgo)).toMatch(/^refreshed \(\d+s ago\)$/)
|
||||
})
|
||||
})
|
||||
@@ -39,6 +39,25 @@ export function formatTimestamp(iso: string | undefined): string {
|
||||
return d.toLocaleString()
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
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}`
|
||||
}
|
||||
|
||||
/** formatLimits renders a LimitsConfig as a short comma-joined summary, or "none" when unset. */
|
||||
export function formatLimits(limits: LimitsConfig | undefined): string {
|
||||
if (!limits) return 'none'
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import {
|
||||
formatRatePercent,
|
||||
isModelDegraded,
|
||||
minuteRateTitle,
|
||||
PROVIDER_RATE_AMBER_THRESHOLD,
|
||||
PROVIDER_RATE_DESTRUCTIVE_THRESHOLD,
|
||||
providerRateStatus,
|
||||
providerSuccessRate,
|
||||
} from './provider-rate'
|
||||
|
||||
describe('providerSuccessRate', () => {
|
||||
it('computes (attempts-failures)/attempts for a healthy provider', () => {
|
||||
expect(providerSuccessRate(100, 1)).toBeCloseTo(0.99)
|
||||
})
|
||||
|
||||
it('computes 1 (100%) for zero failures', () => {
|
||||
expect(providerSuccessRate(50, 0)).toBe(1)
|
||||
})
|
||||
|
||||
it('computes 0 for every attempt failing', () => {
|
||||
expect(providerSuccessRate(10, 10)).toBe(0)
|
||||
})
|
||||
|
||||
it('returns null for zero attempts (no traffic, not a 0/0 NaN)', () => {
|
||||
expect(providerSuccessRate(0, 0)).toBeNull()
|
||||
})
|
||||
|
||||
it('returns null for a negative attempts count (defensive edge case)', () => {
|
||||
expect(providerSuccessRate(-1, 0)).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('providerRateStatus', () => {
|
||||
it('is no-traffic for a null rate', () => {
|
||||
expect(providerRateStatus(null)).toBe('no-traffic')
|
||||
})
|
||||
|
||||
it('is healthy at exactly the amber threshold (boundary is inclusive on the healthy side)', () => {
|
||||
expect(providerRateStatus(PROVIDER_RATE_AMBER_THRESHOLD)).toBe('healthy')
|
||||
})
|
||||
|
||||
it('is healthy at a perfect 100% rate', () => {
|
||||
expect(providerRateStatus(1)).toBe('healthy')
|
||||
})
|
||||
|
||||
it('is degraded just under the amber threshold', () => {
|
||||
expect(providerRateStatus(PROVIDER_RATE_AMBER_THRESHOLD - 0.001)).toBe('degraded')
|
||||
})
|
||||
|
||||
it('is degraded at exactly the destructive threshold (boundary is inclusive on the degraded side)', () => {
|
||||
expect(providerRateStatus(PROVIDER_RATE_DESTRUCTIVE_THRESHOLD)).toBe('degraded')
|
||||
})
|
||||
|
||||
it('is severe just under the destructive threshold', () => {
|
||||
expect(providerRateStatus(PROVIDER_RATE_DESTRUCTIVE_THRESHOLD - 0.001)).toBe('severe')
|
||||
})
|
||||
|
||||
it('is severe at a rate of 0', () => {
|
||||
expect(providerRateStatus(0)).toBe('severe')
|
||||
})
|
||||
})
|
||||
|
||||
describe('isModelDegraded', () => {
|
||||
it('is false with no traffic (attempts 0)', () => {
|
||||
expect(isModelDegraded(0, 0)).toBe(false)
|
||||
})
|
||||
|
||||
it('is false for a healthy model (100%)', () => {
|
||||
expect(isModelDegraded(20, 0)).toBe(false)
|
||||
})
|
||||
|
||||
it('is true for a model below the amber threshold', () => {
|
||||
expect(isModelDegraded(100, 5)).toBe(true)
|
||||
})
|
||||
|
||||
it('is true for a severely degraded model too (severe is a subset of degraded-or-worse)', () => {
|
||||
expect(isModelDegraded(10, 10)).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('formatRatePercent', () => {
|
||||
it('rounds to the nearest whole percent', () => {
|
||||
expect(formatRatePercent(0.994)).toBe('99%')
|
||||
expect(formatRatePercent(0.995)).toBe('100%')
|
||||
expect(formatRatePercent(1)).toBe('100%')
|
||||
expect(formatRatePercent(0)).toBe('0%')
|
||||
})
|
||||
})
|
||||
|
||||
describe('minuteRateTitle', () => {
|
||||
it('reports no traffic distinctly when attemptsMinute is 0', () => {
|
||||
expect(minuteRateTitle(0, 0)).toBe('no traffic in the last minute')
|
||||
})
|
||||
|
||||
it('uses singular attempt/failure wording for a count of exactly 1', () => {
|
||||
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('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%)')
|
||||
})
|
||||
})
|
||||
@@ -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}`
|
||||
}
|
||||
Reference in New Issue
Block a user