diff --git a/cmd/control-plane/hub.go b/cmd/control-plane/hub.go index 622d89d..83c0643 100644 --- a/cmd/control-plane/hub.go +++ b/cmd/control-plane/hub.go @@ -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 { diff --git a/cmd/control-plane/hub_test.go b/cmd/control-plane/hub_test.go index 986c17c..801e7cd 100644 --- a/cmd/control-plane/hub_test.go +++ b/cmd/control-plane/hub_test.go @@ -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 diff --git a/web/src/GatewaysPage.jsx b/web/src/GatewaysPage.jsx index 7c3a74e..27fbd1e 100644 --- a/web/src/GatewaysPage.jsx +++ b/web/src/GatewaysPage.jsx @@ -1,146 +1,370 @@ -import { useState } from 'react' -import { useDataProvider, useList } from '@refinedev/core' +import { useState } from "react"; +import { useDataProvider, useList } from "@refinedev/core"; import { - Alert, Button, ConfirmDialog, Copyable, Field, Input, Modal, PageHeader, PageState, Table, -} from './lib/ui.jsx' -import { useTitle } from './lib/hooks.js' + Alert, + Button, + ConfirmDialog, + Copyable, + Field, + Input, + Modal, + PageHeader, + PageState, + StatusPill, + Table, +} from "./lib/ui.jsx"; +import { useTitle } from "./lib/hooks.js"; -const namePattern = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/ -const tokenPattern = /^[A-Za-z0-9][A-Za-z0-9._-]{15,127}$/ +const namePattern = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/; +const tokenPattern = /^[A-Za-z0-9][A-Za-z0-9._-]{15,127}$/; +const connectivityLabels = { online: "在线", offline: "离线" }; +const healthLabels = { healthy: "健康", unhealthy: "异常" }; + +// 后端对每个网关实时探测 /healthz(连通性)与 /v1/browsers(健康); +// 失败原因通过 title 悬浮展示,不占用列宽。 +function GatewayStatePill({ value, reason, labels, healthyValue }) { + const known = Object.hasOwn(labels, value); + const tone = known + ? value === healthyValue + ? "success" + : "danger" + : "neutral"; + return ( + + + + ); +} function TokenCell({ token, name }) { - const [visible, setVisible] = useState(false) + const [visible, setVisible] = useState(false); return (
- {visible ? token : '••••••••••••'} -