mirror of
https://github.com/lukaszraczylo/traefik-llmgateway.git
synced 2026-10-12 04:54:45 +00:00
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:
@@ -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,
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user