mirror of
https://github.com/lukaszraczylo/traefik-llmgateway.git
synced 2026-10-18 05:47:38 +00:00
feat(webui): surface discovery health state in the Providers tab
Add healthState and openUntil to AdminProviderView, and a formatUntil helper alongside the existing formatAgo/formatTimestamp pair, rendering a future ISO timestamp as "in Ns". ProvidersView.vue shows a Badge next to a provider's other status indicators, destructive for open and secondary for half-open, using the existing shadcn-vue Badge component rather than a hand-rolled indicator. A closed provider, the healthy common case, renders no badge at all, matching the existing convention for the per-model rate badge. The expanded accordion content adds a matching detail row. This was the brief's stated motivation for the admin surfacing work: a broken provider looked identical to a working one in the dashboard. That gap was previously closed at the API layer only; this closes it in the UI clients actually see. Regenerates admin_assets_gen.go from the rebuilt webui via make admin-ui, per this repository's own convention of committing the generated asset alongside its source change.
This commit is contained in:
+6
-6
File diff suppressed because one or more lines are too long
@@ -21,7 +21,7 @@ import {
|
|||||||
CardTitle,
|
CardTitle,
|
||||||
} from '@/components/ui/card'
|
} from '@/components/ui/card'
|
||||||
import { useSearchQuery } from '@/composables/useSearchQuery'
|
import { useSearchQuery } from '@/composables/useSearchQuery'
|
||||||
import { formatAgo, formatContextWindow, refreshDetailLabel, refreshLabel, routableModelId } from '@/lib/format'
|
import { formatAgo, formatContextWindow, formatUntil, refreshDetailLabel, refreshLabel, routableModelId } from '@/lib/format'
|
||||||
import { isModelDegraded } from '@/lib/provider-rate'
|
import { isModelDegraded } from '@/lib/provider-rate'
|
||||||
import { type ExpandState, clearExpandOverrides, computeExpandedItems, toggleItemExpand } from '@/lib/search-expand'
|
import { type ExpandState, clearExpandOverrides, computeExpandedItems, toggleItemExpand } from '@/lib/search-expand'
|
||||||
import { useDashboardStore } from '@/stores/dashboard'
|
import { useDashboardStore } from '@/stores/dashboard'
|
||||||
@@ -169,6 +169,31 @@ function modelMetaFor(p: AdminProviderView, model: string): AdminModelMetaView {
|
|||||||
return p.modelMeta?.[model] ?? ZERO_MODEL_META
|
return p.modelMeta?.[model] ?? ZERO_MODEL_META
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- discovery circuit breaker (feat/provider-health) ---
|
||||||
|
//
|
||||||
|
// Badge shown ONLY for 'open'/'half-open' — mirroring modelIsDegraded's
|
||||||
|
// own "no badge when healthy" convention above: a 'closed' provider (the
|
||||||
|
// common case) renders no breaker badge at all, exactly like a
|
||||||
|
// non-degraded model gets no ProviderRateBadge.
|
||||||
|
|
||||||
|
/** healthBadgeVariant maps healthState to the shadcn-vue Badge variant: destructive (backing off) for 'open', secondary (in progress) for 'half-open', undefined (no badge) for 'closed'. */
|
||||||
|
function healthBadgeVariant(state: AdminProviderView['healthState']): 'destructive' | 'secondary' | undefined {
|
||||||
|
switch (state) {
|
||||||
|
case 'open':
|
||||||
|
return 'destructive'
|
||||||
|
case 'half-open':
|
||||||
|
return 'secondary'
|
||||||
|
default:
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** healthBadgeLabel renders the breaker badge's text: "open (in Ns)" while backing off (formatUntil omits the parenthetical once openUntil has passed or is unset), or "half-open" while probing. */
|
||||||
|
function healthBadgeLabel(p: AdminProviderView): string {
|
||||||
|
const until = formatUntil(p.openUntil)
|
||||||
|
return until ? `${p.healthState} (${until})` : p.healthState
|
||||||
|
}
|
||||||
|
|
||||||
// --- model aliases (sortable DataTable, operator directive) ---
|
// --- model aliases (sortable DataTable, operator directive) ---
|
||||||
//
|
//
|
||||||
// The alias id gets the ModelChip copy treatment (an alias name IS the
|
// The alias id gets the ModelChip copy treatment (an alias name IS the
|
||||||
@@ -295,6 +320,21 @@ const aliasEmptyMessage = computed(() =>
|
|||||||
:attempts-minute="p.attemptsMinute"
|
:attempts-minute="p.attemptsMinute"
|
||||||
:failures-minute="p.failuresMinute"
|
:failures-minute="p.failuresMinute"
|
||||||
/>
|
/>
|
||||||
|
<!--
|
||||||
|
Discovery circuit breaker (feat/provider-health): no
|
||||||
|
badge at all while 'closed' (the healthy, common case),
|
||||||
|
mirroring modelIsDegraded's own convention for the
|
||||||
|
per-model rate badge above.
|
||||||
|
-->
|
||||||
|
<Badge
|
||||||
|
v-if="p.healthState !== 'closed'"
|
||||||
|
as="span"
|
||||||
|
:variant="healthBadgeVariant(p.healthState)"
|
||||||
|
class="font-normal"
|
||||||
|
:title="p.healthState === 'open' ? 'discovery is backing off after repeated failures' : 'a discovery probe is deciding whether to recover'"
|
||||||
|
>
|
||||||
|
{{ healthBadgeLabel(p) }}
|
||||||
|
</Badge>
|
||||||
<FontAwesomeIcon
|
<FontAwesomeIcon
|
||||||
v-if="p.lastErr"
|
v-if="p.lastErr"
|
||||||
:icon="faTriangleExclamation"
|
:icon="faTriangleExclamation"
|
||||||
@@ -314,6 +354,10 @@ const aliasEmptyMessage = computed(() =>
|
|||||||
<dt class="text-xs text-muted-foreground">Last refresh</dt>
|
<dt class="text-xs text-muted-foreground">Last refresh</dt>
|
||||||
<dd>{{ refreshDetailLabel(p.discoveryEnabled, p.lastRefresh) }}</dd>
|
<dd>{{ refreshDetailLabel(p.discoveryEnabled, p.lastRefresh) }}</dd>
|
||||||
</div>
|
</div>
|
||||||
|
<div v-if="p.healthState !== 'closed'">
|
||||||
|
<dt class="text-xs text-muted-foreground">Discovery health</dt>
|
||||||
|
<dd class="text-destructive">{{ healthBadgeLabel(p) }}</dd>
|
||||||
|
</div>
|
||||||
<div v-if="p.lastErr">
|
<div v-if="p.lastErr">
|
||||||
<dt class="text-xs text-muted-foreground">Last error</dt>
|
<dt class="text-xs text-muted-foreground">Last error</dt>
|
||||||
<dd class="text-destructive">{{ p.lastErr }}</dd>
|
<dd class="text-destructive">{{ p.lastErr }}</dd>
|
||||||
|
|||||||
@@ -156,6 +156,24 @@ export function formatAgo(iso: string | undefined): string {
|
|||||||
return ` (${secs}s ago)`
|
return ` (${secs}s ago)`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* formatUntil renders a FUTURE ISO timestamp as "in Ns" — the discovery
|
||||||
|
* circuit breaker's openUntil (feat/provider-health, admin.go's
|
||||||
|
* adminProviderView.OpenUntil) is the only caller — or "" for the unset
|
||||||
|
* zero-time sentinel, an unparseable value, or a time already in the
|
||||||
|
* past (a stale poll reading an openUntil the breaker has since moved
|
||||||
|
* past; the next 5s refresh corrects it, so this just omits the detail
|
||||||
|
* rather than showing a negative/nonsensical countdown).
|
||||||
|
*/
|
||||||
|
export function formatUntil(iso: string | undefined): string {
|
||||||
|
if (!iso || iso === ZERO_TIME) return ''
|
||||||
|
const then = new Date(iso).getTime()
|
||||||
|
if (Number.isNaN(then)) return ''
|
||||||
|
const secs = Math.round((then - Date.now()) / 1000)
|
||||||
|
if (secs <= 0) return ''
|
||||||
|
return `in ${secs}s`
|
||||||
|
}
|
||||||
|
|
||||||
/** formatTimestamp renders an ISO timestamp for display, or "never" for the unset zero-time sentinel. */
|
/** formatTimestamp renders an ISO timestamp for display, or "never" for the unset zero-time sentinel. */
|
||||||
export function formatTimestamp(iso: string | undefined): string {
|
export function formatTimestamp(iso: string | undefined): string {
|
||||||
if (!iso || iso === ZERO_TIME) return 'never'
|
if (!iso || iso === ZERO_TIME) return 'never'
|
||||||
|
|||||||
@@ -64,6 +64,25 @@ export interface AdminProviderView {
|
|||||||
* zero lastRefresh.
|
* zero lastRefresh.
|
||||||
*/
|
*/
|
||||||
discoveryEnabled: boolean
|
discoveryEnabled: boolean
|
||||||
|
/**
|
||||||
|
* healthState is the provider's DISCOVERY circuit breaker state
|
||||||
|
* (admin.go: adminProviderView.HealthState — feat/provider-health):
|
||||||
|
* 'closed' (normal), 'open' (backing off after repeated discovery
|
||||||
|
* failures — openUntil below is when it next probes), or 'half-open'
|
||||||
|
* (a probe is in flight, deciding whether to close again). Reflects
|
||||||
|
* the discovery endpoint ONLY, not request-path health — a provider
|
||||||
|
* whose model listing 401s while its actual completion endpoint works
|
||||||
|
* fine still reads 'open' here. A provider with discovery disabled, or
|
||||||
|
* one that has never failed a refresh, always reads 'closed'.
|
||||||
|
*/
|
||||||
|
healthState: 'closed' | 'open' | 'half-open'
|
||||||
|
/**
|
||||||
|
* openUntil is when this provider's breaker will next attempt a
|
||||||
|
* half-open probe (admin.go: adminProviderView.OpenUntil). The unset
|
||||||
|
* zero-time sentinel (formatAgo/formatTimestamp's ZERO_TIME) when
|
||||||
|
* healthState is not 'open'.
|
||||||
|
*/
|
||||||
|
openUntil: string
|
||||||
/**
|
/**
|
||||||
* attemptsDay/failuresDay/attemptsMinute/failuresMinute are this
|
* attemptsDay/failuresDay/attemptsMinute/failuresMinute are this
|
||||||
* provider's current-window upstream-attempt counters (admin.go:
|
* provider's current-window upstream-attempt counters (admin.go:
|
||||||
|
|||||||
Reference in New Issue
Block a user