feat(gateways): 网关列表增加连通性与健康状态探测展示

- GET /api/gateways 并发探测 /healthz(连通性)与 /v1/browsers(健康),失败原因随列表返回
- 前端新增连通性/健康两列,失败原因悬浮提示
- 覆盖健康、令牌被拒、离线三类探测路径的单元测试
This commit is contained in:
2026-09-03 12:44:49 +08:00
parent a321fd8280
commit e5c720e7d5
4 changed files with 581 additions and 140 deletions
+66 -1
View File
@@ -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/browsersBearer 认证,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 {
+61 -1
View File
@@ -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