mirror of
https://github.com/lukaszraczylo/traefik-llmgateway.git
synced 2026-10-20 05:56:56 +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.
|
// sanitizeBaseURL before this view is ever built.
|
||||||
type adminProviderView struct {
|
type adminProviderView struct {
|
||||||
LastRefresh time.Time `json:"lastRefresh"`
|
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
|
// ModelMeta is this provider's resolved per-model metadata (feature
|
||||||
// v0.23: context window, per-token cost — resolveModelMeta,
|
// v0.23: context window, per-token cost — resolveModelMeta,
|
||||||
// modelmeta.go), keyed by upstream model id. One entry per id in
|
// modelmeta.go), keyed by upstream model id. One entry per id in
|
||||||
@@ -283,7 +289,16 @@ type adminProviderView struct {
|
|||||||
Type string `json:"type"`
|
Type string `json:"type"`
|
||||||
BaseURL string `json:"baseUrl"`
|
BaseURL string `json:"baseUrl"`
|
||||||
LastErr string `json:"lastErr,omitempty"`
|
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
|
// Models is the sorted explicit∪discovered model id set
|
||||||
// (registry.go's providerSnapshot.models — provider-model-accordion
|
// (registry.go's providerSnapshot.models — provider-model-accordion
|
||||||
// task, Vue admin panel): the dashboard's expandable provider row.
|
// task, Vue admin panel): the dashboard's expandable provider row.
|
||||||
@@ -494,6 +509,8 @@ func (g *Gateway) buildAdminOverview() adminOverviewResponse {
|
|||||||
LastRefresh: s.lastRefresh,
|
LastRefresh: s.lastRefresh,
|
||||||
LastErr: sanitizeProviderErr(s.lastErr, s.baseURL),
|
LastErr: sanitizeProviderErr(s.lastErr, s.baseURL),
|
||||||
DiscoveryEnabled: s.discoveryEnabled,
|
DiscoveryEnabled: s.discoveryEnabled,
|
||||||
|
HealthState: s.health.String(),
|
||||||
|
OpenUntil: s.openUntil,
|
||||||
AttemptsDay: pc.attemptsDay,
|
AttemptsDay: pc.attemptsDay,
|
||||||
FailuresDay: pc.failuresDay,
|
FailuresDay: pc.failuresDay,
|
||||||
AttemptsMinute: pc.attemptsMinute,
|
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