diff --git a/webui/src/App.vue b/webui/src/App.vue
index 3153723..8ac058d 100644
--- a/webui/src/App.vue
+++ b/webui/src/App.vue
@@ -1,7 +1,7 @@
+
+
+
+ {{ label }}
+
+
diff --git a/webui/src/components/ProvidersView.vue b/webui/src/components/ProvidersView.vue
index 93a45c1..49143bb 100644
--- a/webui/src/components/ProvidersView.vue
+++ b/webui/src/components/ProvidersView.vue
@@ -9,6 +9,7 @@ import { computed, h, reactive, watch } from 'vue'
import DataTable from '@/components/DataTable.vue'
import ModelChip from '@/components/ModelChip.vue'
+import ProviderRateBadge from '@/components/ProviderRateBadge.vue'
import SearchInput from '@/components/SearchInput.vue'
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from '@/components/ui/accordion'
import { Badge } from '@/components/ui/badge'
@@ -20,10 +21,11 @@ import {
CardTitle,
} from '@/components/ui/card'
import { useSearchQuery } from '@/composables/useSearchQuery'
-import { formatAgo, formatTimestamp, routableModelId } from '@/lib/format'
+import { formatAgo, formatTimestamp, 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'
-import type { AdminAliasView, AdminProviderView } from '@/types/api'
+import type { AdminAliasView, AdminModelRateView, AdminProviderView } from '@/types/api'
// This component backs the "Providers" tab (App.vue) — a UI-label rename
// only. The data it renders still comes from GET /admin/api/overview
@@ -136,6 +138,20 @@ function visibleModels(p: AdminProviderView): string[] {
return hasQuery.value ? p.models.filter((m) => modelMatches(p.name, m)) : p.models
}
+/** 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 }
+
+/** modelRateFor looks up one model's counters within p.modelRates (Feature A, v0.22), falling back to ZERO_MODEL_RATE. */
+function modelRateFor(p: AdminProviderView, model: string): AdminModelRateView {
+ 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. */
+function modelIsDegraded(p: AdminProviderView, model: string): boolean {
+ const r = modelRateFor(p, model)
+ return isModelDegraded(r.attemptsDay, r.failuresDay)
+}
+
// --- model aliases (sortable DataTable, operator directive) ---
//
// The alias id gets the ModelChip copy treatment (an alias name IS the
@@ -244,13 +260,19 @@ const aliasEmptyMessage = computed(() =>
{{ p.name }}
{{ p.type }}
{{ p.modelCount }} models
+
- refreshed {{ formatAgo(p.lastRefresh) || 'never' }}
+ {{ refreshLabel(p.discoveryEnabled, p.lastRefresh) }}
@@ -269,7 +291,16 @@ const aliasEmptyMessage = computed(() =>
no models known yet
diff --git a/webui/src/composables/useTabHash.spec.ts b/webui/src/composables/useTabHash.spec.ts
new file mode 100644
index 0000000..ab76a85
--- /dev/null
+++ b/webui/src/composables/useTabHash.spec.ts
@@ -0,0 +1,49 @@
+import { describe, expect, it } from 'vitest'
+
+import { hashToTab, tabToHash } from './useTabHash'
+
+const TAB_VALUES = ['providers', 'usage', 'charts', 'targets'] as const
+
+describe('hashToTab', () => {
+ it('maps a recognized hash (with leading #) to its tab value', () => {
+ expect(hashToTab('#usage', TAB_VALUES, 'providers')).toBe('usage')
+ })
+
+ it('maps a recognized hash without a leading # too (defensive: a caller might pass window.location.hash.slice(1) already)', () => {
+ expect(hashToTab('charts', TAB_VALUES, 'providers')).toBe('charts')
+ })
+
+ it('falls back to defaultTab for an empty hash (absent, load with no hash at all)', () => {
+ expect(hashToTab('', TAB_VALUES, 'providers')).toBe('providers')
+ })
+
+ it('falls back to defaultTab for a bare "#" (empty fragment)', () => {
+ expect(hashToTab('#', TAB_VALUES, 'providers')).toBe('providers')
+ })
+
+ it('falls back to defaultTab for an unrecognized hash (hand-edited URL)', () => {
+ expect(hashToTab('#nonexistent-tab', TAB_VALUES, 'providers')).toBe('providers')
+ })
+
+ it('is case-sensitive: a differently-cased match still falls back to defaultTab', () => {
+ expect(hashToTab('#Providers', TAB_VALUES, 'providers')).toBe('providers')
+ })
+
+ it('every valid tab round-trips to itself', () => {
+ for (const tab of TAB_VALUES) {
+ expect(hashToTab(`#${tab}`, TAB_VALUES, 'providers')).toBe(tab)
+ }
+ })
+})
+
+describe('tabToHash', () => {
+ it('prefixes the tab value with "#"', () => {
+ expect(tabToHash('targets')).toBe('#targets')
+ })
+
+ it('round-trips through hashToTab back to the same tab', () => {
+ for (const tab of TAB_VALUES) {
+ expect(hashToTab(tabToHash(tab), TAB_VALUES, 'providers')).toBe(tab)
+ }
+ })
+})
diff --git a/webui/src/composables/useTabHash.ts b/webui/src/composables/useTabHash.ts
new file mode 100644
index 0000000..ef7230a
--- /dev/null
+++ b/webui/src/composables/useTabHash.ts
@@ -0,0 +1,85 @@
+import type { Ref } from 'vue'
+import { onMounted, onUnmounted, ref, watch } from 'vue'
+
+// Feature C (v0.22): syncs the active dashboard tab to the URL hash
+// (#providers, #usage, #charts, #targets — App.vue's own TabsTrigger
+// value strings, unchanged) with no router dependency: App.vue's Tabs
+// component already just needs a plain string ref, and a full router is
+// overkill for one hash-backed value. hashToTab/tabToHash are the pure
+// mapping this module's own spec exercises directly (vite.config.ts's
+// `test` block runs in a Node environment — no `window`, no component
+// mount), so every `window`/`history`/`location` touch below is guarded
+// and lives only inside useTabHash itself, never at module scope.
+
+/**
+ * hashToTab maps a raw location.hash string (with or without its leading
+ * "#") to one of validTabs. An absent, empty, or unrecognized hash
+ * returns defaultTab — never throws, and never returns a value outside
+ * 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.
+ */
+export function hashToTab(hash: string, validTabs: readonly T[], defaultTab: T): T {
+ const raw = hash.startsWith('#') ? hash.slice(1) : hash
+ return (validTabs as readonly string[]).includes(raw) ? (raw as T) : defaultTab
+}
+
+/** tabToHash is hashToTab's inverse: the URL hash (leading "#" included) one tab value maps to. */
+export function tabToHash(tab: string): string {
+ return `#${tab}`
+}
+
+export interface UseTabHashOptions {
+ /** The full set of recognized tab values — App.vue's Tabs value strings. */
+ validTabs: readonly T[]
+ /** The tab an absent or unrecognized hash falls back to. */
+ defaultTab: T
+}
+
+/**
+ * useTabHash returns a ref wired bidirectionally to the URL hash:
+ *
+ * - On creation, it reads the CURRENT hash (hashToTab) as its initial
+ * value — the "on load: read hash -> select tab" half of the spec.
+ * - On every ref change (a Tabs v-model update from a user click), it
+ * writes the hash back via history.replaceState — never pushState or a
+ * bare `location.hash =` assignment, both of which would grow browser
+ * history with one entry per tab click and, for the latter, jump
+ * scroll position to any element whose id matches the new hash. A
+ * no-op write (the hash already matches) is skipped so a rapid
+ * double-toggle does not spam replaceState.
+ * - It listens for the browser's own `hashchange` event (back/forward
+ * navigation, or a hand-edited URL) and updates the ref to match —
+ * invalid/unrecognized hands back defaultTab, exactly like the initial
+ * read.
+ *
+ * Guarded for a `window`-less environment (vitest's node test
+ * environment, vite.config.ts): every DOM/history/location access is
+ * behind a single hasWindow check computed once, so importing or calling
+ * this module never throws there — though the composable itself, being a
+ * real Vue lifecycle hook user, is exercised through App.vue in the real
+ * browser, not unit-tested directly; hashToTab/tabToHash above are what
+ * this module's own spec covers.
+ */
+export function useTabHash({ validTabs, defaultTab }: UseTabHashOptions): Ref {
+ const hasWindow = typeof window !== 'undefined'
+ const activeTab = ref(hasWindow ? hashToTab(window.location.hash, validTabs, defaultTab) : defaultTab) as Ref
+
+ watch(activeTab, (tab) => {
+ if (!hasWindow) return
+ const nextHash = tabToHash(tab)
+ if (window.location.hash === nextHash) return
+ window.history.replaceState(null, '', nextHash)
+ })
+
+ function onHashChange(): void {
+ activeTab.value = hashToTab(window.location.hash, validTabs, defaultTab)
+ }
+
+ if (hasWindow) {
+ onMounted(() => window.addEventListener('hashchange', onHashChange))
+ onUnmounted(() => window.removeEventListener('hashchange', onHashChange))
+ }
+
+ return activeTab
+}
diff --git a/webui/src/lib/format.spec.ts b/webui/src/lib/format.spec.ts
new file mode 100644
index 0000000..b6261c2
--- /dev/null
+++ b/webui/src/lib/format.spec.ts
@@ -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\)$/)
+ })
+})
diff --git a/webui/src/lib/format.ts b/webui/src/lib/format.ts
index 97bc0ed..ac69439 100644
--- a/webui/src/lib/format.ts
+++ b/webui/src/lib/format.ts
@@ -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'
diff --git a/webui/src/lib/provider-rate.spec.ts b/webui/src/lib/provider-rate.spec.ts
new file mode 100644
index 0000000..42e6616
--- /dev/null
+++ b/webui/src/lib/provider-rate.spec.ts
@@ -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%)')
+ })
+})
diff --git a/webui/src/lib/provider-rate.ts b/webui/src/lib/provider-rate.ts
new file mode 100644
index 0000000..0db8b8a
--- /dev/null
+++ b/webui/src/lib/provider-rate.ts
@@ -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}`
+}
diff --git a/webui/src/types/api.ts b/webui/src/types/api.ts
index bf43464..978018c 100644
--- a/webui/src/types/api.ts
+++ b/webui/src/types/api.ts
@@ -12,6 +12,19 @@ export interface LimitsConfig {
costPerMonthUSD?: number
}
+/**
+ * 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.
+ */
+export interface AdminModelRateView {
+ attemptsDay: number
+ failuresDay: number
+ attemptsMinute: number
+ failuresMinute: number
+}
+
export interface AdminProviderView {
name: string
type: string
@@ -21,6 +34,27 @@ export interface AdminProviderView {
/** Sorted explicit∪discovered model ids (admin.go: adminProviderView.Models) — always an array, never omitted, even when empty. */
models: string[]
modelCount: number
+ /**
+ * discoveryEnabled mirrors the provider's own configured Discovery flag
+ * (admin.go: adminProviderView.DiscoveryEnabled — Feature B, v0.22): the
+ * Providers tab uses it to tell "discovery is off" apart from "discovery
+ * is on but has not refreshed yet", both of which otherwise show the same
+ * zero lastRefresh.
+ */
+ discoveryEnabled: boolean
+ /**
+ * attemptsDay/failuresDay/attemptsMinute/failuresMinute are this
+ * provider's current-window upstream-attempt counters (admin.go:
+ * adminProviderView's identically-named fields — Feature A, v0.22). See
+ * lib/provider-rate.ts for the success-rate math and badge-tier
+ * thresholds built on top of these.
+ */
+ attemptsDay: number
+ failuresDay: number
+ attemptsMinute: number
+ failuresMinute: number
+ /** Per-model breakdown of the counters above, keyed by upstream model id — always present (possibly empty), never omitted. */
+ modelRates: Record
}
export interface AdminRedisView {