feat(gateways): 网关列表增加连通性与健康状态探测展示
- GET /api/gateways 并发探测 /healthz(连通性)与 /v1/browsers(健康),失败原因随列表返回 - 前端新增连通性/健康两列,失败原因悬浮提示 - 覆盖健康、令牌被拒、离线三类探测路径的单元测试
This commit is contained in:
@@ -216,6 +216,71 @@ func gatewayUnreachable(err error) error {
|
||||
return gatewayFailure{status: http.StatusBadGateway, message: fmt.Sprintf("gateway unreachable: %v", err)}
|
||||
}
|
||||
|
||||
// gatewayStatus 在网关基础信息上补充实时探测结果:connectivity 探测 /healthz(无认证,
|
||||
// 收到任意 HTTP 响应即在线),health 探测 /v1/browsers(Bearer 认证,200 且容器列表可
|
||||
// 解析为健康)。失败原因原样返回给页面,不吞掉。
|
||||
type gatewayStatus struct {
|
||||
hub.Gateway
|
||||
Connectivity string `json:"connectivity"` // online | offline
|
||||
ConnectivityReason string `json:"connectivity_reason,omitempty"` // 仅离线时携带
|
||||
Health string `json:"health"` // healthy | unhealthy
|
||||
HealthReason string `json:"health_reason,omitempty"` // 仅异常时携带
|
||||
}
|
||||
|
||||
const gatewayHealthzTimeout = 5 * time.Second
|
||||
|
||||
// gatewayStatusList 并发探测全部网关;单个网关失败不影响其余结果与接口返回。
|
||||
// 结果经由带缓冲 channel 回收,由收集协程独占写入,避免共享写。
|
||||
func gatewayStatusList(ctx context.Context, gateways []hub.Gateway) []gatewayStatus {
|
||||
results := make(chan struct {
|
||||
index int
|
||||
status gatewayStatus
|
||||
}, len(gateways))
|
||||
for index, target := range gateways {
|
||||
go func(index int, target hub.Gateway) {
|
||||
results <- struct {
|
||||
index int
|
||||
status gatewayStatus
|
||||
}{index, probeGatewayStatus(ctx, target)}
|
||||
}(index, target)
|
||||
}
|
||||
statuses := make([]gatewayStatus, len(gateways))
|
||||
for range gateways {
|
||||
result := <-results
|
||||
statuses[result.index] = result.status
|
||||
}
|
||||
return statuses
|
||||
}
|
||||
|
||||
func probeGatewayStatus(ctx context.Context, target hub.Gateway) gatewayStatus {
|
||||
status := gatewayStatus{Gateway: target, Connectivity: "offline", Health: "unhealthy"}
|
||||
probeCtx, cancel := context.WithTimeout(ctx, gatewayHealthzTimeout)
|
||||
defer cancel()
|
||||
request, requestErr := http.NewRequestWithContext(probeCtx, http.MethodGet, target.Endpoint+"/healthz", nil)
|
||||
if requestErr != nil {
|
||||
status.ConnectivityReason = requestErr.Error()
|
||||
} else if response, callErr := http.DefaultClient.Do(request); callErr != nil {
|
||||
status.ConnectivityReason = callErr.Error()
|
||||
} else {
|
||||
_ = response.Body.Close()
|
||||
status.Connectivity = "online"
|
||||
}
|
||||
code, body, callErr := gatewayCall(ctx, target, http.MethodGet, "/v1/browsers", nil, 30*time.Second)
|
||||
switch {
|
||||
case callErr != nil:
|
||||
status.HealthReason = callErr.Error()
|
||||
case code != http.StatusOK:
|
||||
status.HealthReason = fmt.Sprintf("gateway call failed with status %d", code)
|
||||
default:
|
||||
if _, parseErr := parseGatewayBrowserList(body); parseErr != nil {
|
||||
status.HealthReason = "gateway returned an invalid browser list"
|
||||
} else {
|
||||
status.Health = "healthy"
|
||||
}
|
||||
}
|
||||
return status
|
||||
}
|
||||
|
||||
func reconcileGatewayContainer(ctx context.Context, target hub.Gateway, alias string) (containerStatus, bool, error) {
|
||||
var lastErr error
|
||||
for attempt := 0; attempt < gatewayReconcileAttempts; attempt++ {
|
||||
@@ -458,7 +523,7 @@ func registerHubWithNetwork(app *fiber.App, store hubStore, probe networkExitPro
|
||||
if err != nil {
|
||||
return hubError(c, err)
|
||||
}
|
||||
return c.JSON(gateways)
|
||||
return c.JSON(gatewayStatusList(c.Context(), gateways))
|
||||
})
|
||||
app.Post("/api/gateways", func(c fiber.Ctx) error {
|
||||
input := struct {
|
||||
|
||||
@@ -156,7 +156,15 @@ func (s *memoryStore) lock(key string) func() {
|
||||
func (s *memoryStore) CreateGateway(_ context.Context, _, _, _ string) (hub.Gateway, error) {
|
||||
return hub.Gateway{}, nil
|
||||
}
|
||||
func (s *memoryStore) ListGateways(context.Context) ([]hub.Gateway, error) { return nil, nil }
|
||||
func (s *memoryStore) ListGateways(context.Context) ([]hub.Gateway, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
gateways := make([]hub.Gateway, 0, len(s.gateways))
|
||||
for _, gateway := range s.gateways {
|
||||
gateways = append(gateways, gateway)
|
||||
}
|
||||
return gateways, nil
|
||||
}
|
||||
func (s *memoryStore) GetGateway(_ context.Context, name string) (hub.Gateway, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
@@ -635,6 +643,10 @@ func (probe *sequenceExitProbe) Check(context.Context, hub.NetworkExitAccess) (h
|
||||
|
||||
func (g *fakeGateway) handler(t *testing.T) http.Handler {
|
||||
return http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) {
|
||||
if request.Method == http.MethodGet && request.URL.Path == "/healthz" {
|
||||
response.WriteHeader(http.StatusNoContent)
|
||||
return
|
||||
}
|
||||
if request.Header.Get("Authorization") != "Bearer "+g.token {
|
||||
response.WriteHeader(http.StatusUnauthorized)
|
||||
_, _ = response.Write([]byte(`{"error":"gateway token rejected"}`))
|
||||
@@ -855,6 +867,54 @@ func do(app *fiber.App, method, path, body string, credentials ...string) *httpt
|
||||
const createEnvBody = `{"alias":"account-a","name":"店铺一号","gateway":"gw-1","image_version":"148.0.7778.215",` +
|
||||
`"fingerprint":{"seed":2024,"platform":"windows","timezone":"Asia/Shanghai"},"account_id":"account-a","network_exit_id":"exit-1"}`
|
||||
|
||||
func TestListGatewaysExposesConnectivityAndHealth(t *testing.T) {
|
||||
store := newMemoryStore()
|
||||
// gw-1:正常网关,/healthz 可达且 /v1/browsers 可认证。
|
||||
gateway := &fakeGateway{token: "unit-test-gateway-token"}
|
||||
server := httptest.NewServer(gateway.handler(t))
|
||||
t.Cleanup(server.Close)
|
||||
store.gateways["gw-1"] = hub.Gateway{Name: "gw-1", Endpoint: server.URL, Token: gateway.token}
|
||||
// gw-auth:/healthz 可达但令牌被拒,应区分连通与 API 健康。
|
||||
authServer := httptest.NewServer(http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) {
|
||||
if request.Method == http.MethodGet && request.URL.Path == "/healthz" {
|
||||
response.WriteHeader(http.StatusNoContent)
|
||||
return
|
||||
}
|
||||
response.WriteHeader(http.StatusUnauthorized)
|
||||
}))
|
||||
t.Cleanup(authServer.Close)
|
||||
store.gateways["gw-auth"] = hub.Gateway{Name: "gw-auth", Endpoint: authServer.URL, Token: "unit-test-gateway-token"}
|
||||
// gw-offline:端口已关闭,两个探测都应失败并携带原因。
|
||||
closed := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {}))
|
||||
closedURL := closed.URL
|
||||
closed.Close()
|
||||
store.gateways["gw-offline"] = hub.Gateway{Name: "gw-offline", Endpoint: closedURL, Token: "offline-token-123456789"}
|
||||
app := fiber.New()
|
||||
registerHubWithNetwork(app, store, fakeExitProbe{}, func(hub.NetworkExitAccess) (string, error) { return "", nil })
|
||||
|
||||
response := do(app, http.MethodGet, "/api/gateways", "")
|
||||
if response.Code != http.StatusOK {
|
||||
t.Fatalf("list gateways returned %d: %s", response.Code, response.Body.String())
|
||||
}
|
||||
var statuses []gatewayStatus
|
||||
if err := json.Unmarshal(response.Body.Bytes(), &statuses); err != nil || len(statuses) != 3 {
|
||||
t.Fatalf("decode gateway statuses failed: statuses=%#v err=%v", statuses, err)
|
||||
}
|
||||
byName := map[string]gatewayStatus{}
|
||||
for _, status := range statuses {
|
||||
byName[status.Name] = status
|
||||
}
|
||||
if status := byName["gw-1"]; status.Connectivity != "online" || status.Health != "healthy" || status.ConnectivityReason != "" || status.HealthReason != "" {
|
||||
t.Fatalf("healthy gateway reported %#v", status)
|
||||
}
|
||||
if status := byName["gw-auth"]; status.Connectivity != "online" || status.Health != "unhealthy" || status.HealthReason == "" {
|
||||
t.Fatalf("auth-rejected gateway reported %#v", status)
|
||||
}
|
||||
if status := byName["gw-offline"]; status.Connectivity != "offline" || status.Health != "unhealthy" || status.ConnectivityReason == "" || status.HealthReason == "" {
|
||||
t.Fatalf("offline gateway reported %#v", status)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseGatewayBrowserListStrict(t *testing.T) {
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
|
||||
Reference in New Issue
Block a user