feat(admin): surface provider health state in overview

Add HealthState and OpenUntil to adminProviderView, GET
/admin/api/overview's per-provider row. HealthState mirrors the
discovery circuit breaker's state: closed, open, or half-open.
OpenUntil is the zero time.Time unless the breaker is open.

A provider with discovery disabled, or one that has never failed a
refresh, always reads closed. This keeps the dashboard's default view
unchanged for a healthy deployment.

Both fields come from providerSnapshot, read in the same locked pass
as the existing lastRefresh/lastErr fields, so a concurrent
finishRefresh can never hand the dashboard a breaker state that
disagrees with the rest of the row.
This commit is contained in:
2026-08-22 22:14:20 +01:00
parent e9ce6b873d
commit 619931b2b2
2 changed files with 113 additions and 1 deletions
+18 -1
View File
@@ -261,6 +261,12 @@ func sanitizeProviderErr(lastErr, rawBaseURL string) string {
// sanitizeBaseURL before this view is ever built.
type adminProviderView struct {
LastRefresh time.Time `json:"lastRefresh"`
// OpenUntil is when this provider's breaker will next attempt a
// half-open probe (feat/provider-health). The zero time.Time when
// HealthState is not "open". Grouped with LastRefresh above (both
// time.Time) for fieldalignment — see providerState's own doc
// comment for the convention this struct follows.
OpenUntil time.Time `json:"openUntil"`
// ModelMeta is this provider's resolved per-model metadata (feature
// v0.23: context window, per-token cost — resolveModelMeta,
// modelmeta.go), keyed by upstream model id. One entry per id in
@@ -283,7 +289,16 @@ type adminProviderView struct {
Type string `json:"type"`
BaseURL string `json:"baseUrl"`
LastErr string `json:"lastErr,omitempty"`
Name string `json:"name"`
// HealthState is this provider's discovery circuit breaker state
// (feat/provider-health): "closed" (normal), "open" (backing off
// after repeated discovery failures — maybeRefresh skips it until
// OpenUntil above), or "half-open" (a probe refresh is in flight,
// deciding whether to close the breaker again). A provider with
// discovery disabled, or one that has never failed a refresh, always
// reads "closed" — it has no health signal that could trip the
// breaker.
HealthState string `json:"healthState"`
Name string `json:"name"`
// Models is the sorted explicit∪discovered model id set
// (registry.go's providerSnapshot.models — provider-model-accordion
// task, Vue admin panel): the dashboard's expandable provider row.
@@ -494,6 +509,8 @@ func (g *Gateway) buildAdminOverview() adminOverviewResponse {
LastRefresh: s.lastRefresh,
LastErr: sanitizeProviderErr(s.lastErr, s.baseURL),
DiscoveryEnabled: s.discoveryEnabled,
HealthState: s.health.String(),
OpenUntil: s.openUntil,
AttemptsDay: pc.attemptsDay,
FailuresDay: pc.failuresDay,
AttemptsMinute: pc.attemptsMinute,
+95
View File
@@ -0,0 +1,95 @@
package traefikllmgateway
import (
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"testing"
)
// findAdminProvider returns the entry named name from got.Providers, or
// fails the test — every admin_health_test.go case looks up exactly one
// provider by name out of newAdminTestConfig's fixed "zeta"/"alpha" pair.
func findAdminProvider(t *testing.T, got adminOverviewResponse, name string) *adminProviderView {
t.Helper()
for i := range got.Providers {
if got.Providers[i].Name == name {
return &got.Providers[i]
}
}
t.Fatalf("providers missing %q: %+v", name, got.Providers)
return nil
}
// TestAdminOverview_HealthState_DefaultClosed proves GET
// /admin/api/overview reports "closed" for every provider in a freshly
// constructed, never-failing Gateway (feat/provider-health) — the
// dashboard's default view for a healthy deployment is unaffected by this
// feature existing.
func TestAdminOverview_HealthState_DefaultClosed(t *testing.T) {
t.Parallel()
cfg := newAdminTestConfig()
h, _ := newAdminGatewayHandle(t, cfg)
rec := httptest.NewRecorder()
h.ServeHTTP(rec, adminRequest(http.MethodGet, adminOverviewPath, "sk-admin1"))
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200, body=%s", rec.Code, rec.Body.String())
}
var got adminOverviewResponse
if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil {
t.Fatalf("decode: %v", err)
}
for _, name := range []string{"zeta", "alpha"} {
p := findAdminProvider(t, got, name)
if p.HealthState != "closed" {
t.Errorf("provider %q HealthState = %q, want %q", name, p.HealthState, "closed")
}
if !p.OpenUntil.IsZero() {
t.Errorf("provider %q OpenUntil = %v, want zero", name, p.OpenUntil)
}
}
}
// TestAdminOverview_HealthState_ReflectsOpenBreaker proves GET
// /admin/api/overview surfaces a tripped breaker (feat/provider-health) —
// the dashboard's whole reason for carrying these two fields — without
// affecting a healthy sibling provider's own view.
func TestAdminOverview_HealthState_ReflectsOpenBreaker(t *testing.T) {
t.Parallel()
cfg := newAdminTestConfig()
cfg.Breaker = BreakerConfig{FailureThreshold: 2, OpenDuration: "1m", MaxOpenDuration: "10m"}
h, gw := newAdminGatewayHandle(t, cfg)
boom := errors.New("boom: discovery unreachable")
st := gw.registry.states["alpha"]
st.finishRefresh(gw.registry.now(), nil, boom)
st.finishRefresh(gw.registry.now(), nil, boom) // reaches FailureThreshold: 2, opens the breaker
rec := httptest.NewRecorder()
h.ServeHTTP(rec, adminRequest(http.MethodGet, adminOverviewPath, "sk-admin1"))
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200, body=%s", rec.Code, rec.Body.String())
}
var got adminOverviewResponse
if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil {
t.Fatalf("decode: %v", err)
}
alpha := findAdminProvider(t, got, "alpha")
if alpha.HealthState != "open" {
t.Errorf("alpha HealthState = %q, want %q", alpha.HealthState, "open")
}
if alpha.OpenUntil.IsZero() {
t.Error("alpha OpenUntil = zero, want a non-zero time while the breaker is open")
}
zeta := findAdminProvider(t, got, "zeta")
if zeta.HealthState != "closed" {
t.Errorf("zeta HealthState = %q, want %q — a sibling provider's breaker must not affect it", zeta.HealthState, "closed")
}
}