Last refresh
{{ refreshDetailLabel(p.discoveryEnabled, p.lastRefresh) }}
+
+
Discovery health
+ {{ healthBadgeLabel(p) }}
+
Last error
{{ p.lastErr }}
diff --git a/webui/src/lib/format.spec.ts b/webui/src/lib/format.spec.ts
index c90bc01..dfc6d50 100644
--- a/webui/src/lib/format.spec.ts
+++ b/webui/src/lib/format.spec.ts
@@ -1,6 +1,6 @@
import { describe, expect, it } from 'vitest'
-import { formatCompactCount, formatContextWindow, formatModelCostHover, refreshDetailLabel, refreshLabel } from './format'
+import { formatCompactCount, formatContextWindow, formatModelCostHover, formatUntil, refreshDetailLabel, refreshLabel } from './format'
const ZERO_TIME = '0001-01-01T00:00:00Z'
@@ -98,6 +98,30 @@ describe('formatModelCostHover', () => {
})
})
+// Discovery circuit breaker (feat/provider-health, round 3): openUntil's
+// future-time counterpart to formatAgo's past-time "(Ns ago)". Only
+// caller today is ProvidersView.vue's healthBadgeLabel, rendering the
+// breaker's live "open (in Ns)" countdown.
+describe('formatUntil', () => {
+ it('renders "" for the unset zero-time sentinel', () => {
+ expect(formatUntil(ZERO_TIME)).toBe('')
+ })
+
+ it('renders "" for undefined', () => {
+ expect(formatUntil(undefined)).toBe('')
+ })
+
+ it('renders "in Ns" for a future timestamp', () => {
+ const inFortyTwoSeconds = new Date(Date.now() + 42_000).toISOString()
+ expect(formatUntil(inFortyTwoSeconds)).toMatch(/^in \d+s$/)
+ })
+
+ it('renders "" for a timestamp already in the past — a stale poll must not show a negative countdown', () => {
+ const oneMinuteAgo = new Date(Date.now() - 60_000).toISOString()
+ expect(formatUntil(oneMinuteAgo)).toBe('')
+ })
+})
+
// Usage-count compact display (v0.23 addendum): SI-style, 3 significant
// figures, floor-not-round (the same honesty rule formatRatePercent
// already applies to the success-rate badge — a count must never appear
diff --git a/webui/src/lib/format.ts b/webui/src/lib/format.ts
index b21f431..c0b332b 100644
--- a/webui/src/lib/format.ts
+++ b/webui/src/lib/format.ts
@@ -156,6 +156,24 @@ export function formatAgo(iso: string | undefined): string {
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. */
export function formatTimestamp(iso: string | undefined): string {
if (!iso || iso === ZERO_TIME) return 'never'
diff --git a/webui/src/types/api.ts b/webui/src/types/api.ts
index 0b7821b..11924e4 100644
--- a/webui/src/types/api.ts
+++ b/webui/src/types/api.ts
@@ -64,6 +64,25 @@ export interface AdminProviderView {
* zero lastRefresh.
*/
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
* provider's current-window upstream-attempt counters (admin.go: