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:
2026-08-22 22:57:19 +01:00
parent e82dabb1dc
commit 8a3f9e7d00
4 changed files with 88 additions and 7 deletions
+45 -1
View File
@@ -21,7 +21,7 @@ import {
CardTitle,
} from '@/components/ui/card'
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 { type ExpandState, clearExpandOverrides, computeExpandedItems, toggleItemExpand } from '@/lib/search-expand'
import { useDashboardStore } from '@/stores/dashboard'
@@ -169,6 +169,31 @@ function modelMetaFor(p: AdminProviderView, model: string): AdminModelMetaView {
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) ---
//
// The alias id gets the ModelChip copy treatment (an alias name IS the
@@ -295,6 +320,21 @@ const aliasEmptyMessage = computed(() =>
:attempts-minute="p.attemptsMinute"
: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
v-if="p.lastErr"
:icon="faTriangleExclamation"
@@ -314,6 +354,10 @@ const aliasEmptyMessage = computed(() =>
<dt class="text-xs text-muted-foreground">Last refresh</dt>
<dd>{{ refreshDetailLabel(p.discoveryEnabled, p.lastRefresh) }}</dd>
</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">
<dt class="text-xs text-muted-foreground">Last error</dt>
<dd class="text-destructive">{{ p.lastErr }}</dd>