mirror of
https://github.com/lukaszraczylo/traefik-llmgateway.git
synced 2026-10-14 05:18:54 +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:
+12
-2
@@ -1,7 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { faGithub } from '@fortawesome/free-brands-svg-icons'
|
||||
import { faCircleExclamation } from '@fortawesome/free-solid-svg-icons'
|
||||
import { computed, defineAsyncComponent, onMounted, ref } from 'vue'
|
||||
import { computed, defineAsyncComponent, onMounted } from 'vue'
|
||||
|
||||
import AuthGate from '@/components/AuthGate.vue'
|
||||
import ProvidersView from '@/components/ProvidersView.vue'
|
||||
@@ -9,6 +9,7 @@ import TargetsView from '@/components/TargetsView.vue'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
||||
import UsageView from '@/components/UsageView.vue'
|
||||
import { useTabHash } from '@/composables/useTabHash'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { useDashboardStore } from '@/stores/dashboard'
|
||||
|
||||
@@ -29,7 +30,16 @@ const dashboard = useDashboardStore()
|
||||
// fetches from, GET /admin/api/overview, and the store's own `overview`
|
||||
// field (AdminOverviewResponse) keep their original names unchanged — the
|
||||
// rename is UI-only, not an API surface change.
|
||||
const activeView = ref<'providers' | 'usage' | 'charts' | 'targets'>('providers')
|
||||
const TAB_VALUES = ['providers', 'usage', 'charts', 'targets'] as const
|
||||
type TabValue = (typeof TAB_VALUES)[number]
|
||||
|
||||
// Feature C (v0.22): activeView is now hash-backed (composables/
|
||||
// useTabHash.ts) instead of a bare ref — same v-model contract Tabs
|
||||
// already expects, so nothing else about this template changes. AuthGate's
|
||||
// own flow is untouched: it never reads or writes the hash, so whatever
|
||||
// tab the URL named survives straight through the unauthenticated ->
|
||||
// authenticated transition with no extra wiring here.
|
||||
const activeView = useTabHash<TabValue>({ validTabs: TAB_VALUES, defaultTab: 'providers' })
|
||||
|
||||
const statusText = computed<string>(() => {
|
||||
if (dashboard.error) return `refresh failed: ${dashboard.error}`
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { 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.
|
||||
*/
|
||||
const props = defineProps<{
|
||||
attemptsDay: number
|
||||
failuresDay: number
|
||||
attemptsMinute: number
|
||||
failuresMinute: number
|
||||
}>()
|
||||
|
||||
const rate = computed(() => providerSuccessRate(props.attemptsDay, props.failuresDay))
|
||||
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))
|
||||
|
||||
// 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".
|
||||
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 === 'no-traffic') return 'text-muted-foreground'
|
||||
return ''
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Badge as="span" :variant="variant" :class="extraClass" class="font-normal tabular-nums" :title="title">
|
||||
{{ label }}
|
||||
</Badge>
|
||||
</template>
|
||||
@@ -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(() =>
|
||||
<span class="font-medium">{{ p.name }}</span>
|
||||
<Badge as="span" variant="secondary" class="font-normal">{{ p.type }}</Badge>
|
||||
<span class="text-xs text-muted-foreground tabular-nums">{{ p.modelCount }} models</span>
|
||||
<ProviderRateBadge
|
||||
:attempts-day="p.attemptsDay"
|
||||
:failures-day="p.failuresDay"
|
||||
:attempts-minute="p.attemptsMinute"
|
||||
:failures-minute="p.failuresMinute"
|
||||
/>
|
||||
<FontAwesomeIcon
|
||||
v-if="p.lastErr"
|
||||
:icon="faTriangleExclamation"
|
||||
class="size-3.5 shrink-0 text-destructive"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span class="text-xs text-muted-foreground">refreshed {{ formatAgo(p.lastRefresh) || 'never' }}</span>
|
||||
<span class="text-xs text-muted-foreground">{{ refreshLabel(p.discoveryEnabled, p.lastRefresh) }}</span>
|
||||
</span>
|
||||
</AccordionTrigger>
|
||||
<AccordionContent>
|
||||
@@ -269,7 +291,16 @@ const aliasEmptyMessage = computed(() =>
|
||||
</div>
|
||||
</dl>
|
||||
<div v-if="visibleModels(p).length" class="flex flex-wrap gap-1.5">
|
||||
<ModelChip v-for="m in visibleModels(p)" :key="m" :id="routableModelId(p.name, m)" />
|
||||
<span v-for="m in visibleModels(p)" :key="m" class="inline-flex items-center gap-1">
|
||||
<ModelChip :id="routableModelId(p.name, m)" />
|
||||
<ProviderRateBadge
|
||||
v-if="modelIsDegraded(p, m)"
|
||||
:attempts-day="modelRateFor(p, m).attemptsDay"
|
||||
:failures-day="modelRateFor(p, m).failuresDay"
|
||||
:attempts-minute="modelRateFor(p, m).attemptsMinute"
|
||||
:failures-minute="modelRateFor(p, m).failuresMinute"
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
<p v-else class="text-sm text-muted-foreground">no models known yet</p>
|
||||
</AccordionContent>
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -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<T extends string>(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<T extends string> {
|
||||
/** 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<T> 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<T extends string>({ validTabs, defaultTab }: UseTabHashOptions<T>): Ref<T> {
|
||||
const hasWindow = typeof window !== 'undefined'
|
||||
const activeTab = ref(hasWindow ? hashToTab(window.location.hash, validTabs, defaultTab) : defaultTab) as Ref<T>
|
||||
|
||||
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
|
||||
}
|
||||
@@ -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}`
|
||||
}
|
||||
@@ -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<string, AdminModelRateView>
|
||||
}
|
||||
|
||||
export interface AdminRedisView {
|
||||
|
||||
Reference in New Issue
Block a user