From 1fbf126b21a7d6de45ade70121785075542363d9 Mon Sep 17 00:00:00 2001 From: Rogee Date: Fri, 11 Sep 2026 09:35:28 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E5=88=97=E8=A1=A8=E8=AF=A6=E6=83=85?= =?UTF-8?q?=E9=A1=B5=E5=8F=96=E6=B6=88=E7=8A=B6=E6=80=81=E6=A3=80=E6=B5=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- cmd/control-plane/hub.go | 90 +---------- cmd/control-plane/hub_test.go | 288 +++++++++++++++------------------- web/src/BrowsersPage.jsx | 5 +- web/src/BrowsersPage.test.jsx | 8 + web/src/GatewaysPage.jsx | 56 +------ web/src/GatewaysPage.test.jsx | 25 +-- 6 files changed, 157 insertions(+), 315 deletions(-) diff --git a/cmd/control-plane/hub.go b/cmd/control-plane/hub.go index 875edf8..d0bb7a3 100644 --- a/cmd/control-plane/hub.go +++ b/cmd/control-plane/hub.go @@ -245,71 +245,6 @@ 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++ { @@ -483,7 +418,7 @@ func registerHub(app *fiber.App, store hubStore) { } func registerHubWithNetwork(app *fiber.App, store hubStore, probe networkExitProbe, resolve func(hub.NetworkExitAccess) (string, error)) { - app.Get("/api/browsers", listBrowsers(store, probe, resolve)) + app.Get("/api/browsers", listBrowsers(store)) app.Get("/api/browsers/:alias", getBrowser(store)) app.Post("/api/browsers", lockBrowserCreate(store, createBrowser(store, probe, resolve))) app.Post("/api/browsers/:alias/:action", lockBrowserAlias(store, browserAction(store, probe, resolve))) @@ -552,7 +487,7 @@ func registerHubWithNetwork(app *fiber.App, store hubStore, probe networkExitPro if err != nil { return hubError(c, err) } - return c.JSON(gatewayStatusList(c.Context(), gateways)) + return c.JSON(gateways) }) app.Post("/api/gateways", func(c fiber.Ctx) error { input := struct { @@ -942,38 +877,29 @@ func verifyNetworkExit(ctx context.Context, store hubStore, probe networkExitPro return access, reason, err } -func listBrowsers(store hubStore, probe networkExitProbe, resolve func(hub.NetworkExitAccess) (string, error)) fiber.Handler { +func listBrowsers(store hubStore) fiber.Handler { return func(c fiber.Ctx) error { envs, err := store.ListEnvs(c.Context()) if err != nil { return hubError(c, err) } - unlock, err := lockEnvironmentResources(c.Context(), store, envs) - if err != nil { - return hubError(c, err) - } - defer unlock() - containers, gatewayRead, gatewayErrors := gatewayContainerSnapshot(c.Context(), store, envs) - if err := reconcileRuntimeSnapshot(c.Context(), store, probe, resolve, envs, containers, gatewayRead, gatewayErrors); err != nil { - return hubError(c, err) - } views := make([]envView, 0, len(envs)) for _, env := range envs { - view := envView{Env: env, State: "missing", Status: "网关上不存在容器", ScheduleStatus: "blocked", ScheduleBlockReason: "binding_missing", RecoveryRequired: true} + view := envView{Env: env, State: "missing", Status: "未记录运行实例", ScheduleStatus: "blocked", ScheduleBlockReason: "binding_missing", RecoveryRequired: true} if environment, contextErr := store.GetEnvironmentContext(c.Context(), env.Alias); contextErr == nil { view.AccountID, view.AccountStatus, view.AuthorizationStatus = environment.AccountID, environment.AccountStatus, environment.AuthorizationStatus view.NetworkExitID, view.NetworkExitHealth, view.BindingVersion = environment.Exit.ID, environment.Exit.HealthStatus, environment.BindingVersion view.RuntimeInstanceID = environment.RuntimeInstanceID + view.ContainerID = environment.RuntimeID view.ScheduleStatus, view.ScheduleBlockReason = environmentScheduleReadiness(environment) view.CleanupPending = environment.RuntimeCleanupPending view.RecoveryRequired = (environment.Exit.ID != "" && environment.Exit.HealthStatus != "healthy") || environment.RuntimeCleanupPending + if environment.RuntimeInstanceID != "" { + view.State, view.Status = "running", "已记录运行实例" + } } else if !errors.Is(contextErr, hub.ErrNotFound) { return hubError(c, contextErr) } - if container, ok := containers[env.Gateway][env.Alias]; ok { - view.State, view.Status = container.State, container.Status - view.ContainerID, view.Endpoint = container.ID, container.Endpoint - } views = append(views, view) } return c.JSON(views) diff --git a/cmd/control-plane/hub_test.go b/cmd/control-plane/hub_test.go index c30fcb9..1745d46 100644 --- a/cmd/control-plane/hub_test.go +++ b/cmd/control-plane/hub_test.go @@ -926,28 +926,13 @@ func TestUpdateGatewayRenamesAndPreservesReferences(t *testing.T) { } } -func TestListGatewaysExposesConnectivityAndHealth(t *testing.T) { +func TestListGatewaysReturnsStoredRecordsWithoutProbing(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"} + stored := hub.Gateway{Name: "gw-1", Endpoint: server.URL, Token: gateway.token} + store.gateways[stored.Name] = stored app := fiber.New() registerHubWithNetwork(app, store, fakeExitProbe{}, func(hub.NetworkExitAccess) (string, error) { return "", nil }) @@ -955,22 +940,15 @@ func TestListGatewaysExposesConnectivityAndHealth(t *testing.T) { 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) + var gateways []hub.Gateway + if err := json.Unmarshal(response.Body.Bytes(), &gateways); err != nil || len(gateways) != 1 || gateways[0] != stored { + t.Fatalf("stored gateway was not returned unchanged: gateways=%#v err=%v", gateways, err) } - byName := map[string]gatewayStatus{} - for _, status := range statuses { - byName[status.Name] = status + if strings.Contains(response.Body.String(), "connectivity") || strings.Contains(response.Body.String(), "health") { + t.Fatalf("gateway list exposed live status fields: %s", response.Body.String()) } - 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) + if requests := gateway.recorded(); len(requests) != 0 { + t.Fatalf("gateway list performed live probes: %#v", requests) } } @@ -1534,14 +1512,18 @@ func TestCreateBrowserRejectsInvalidFingerprintBeforeSideEffects(t *testing.T) { } } -func TestListBrowsersMergesLiveGatewayState(t *testing.T) { +func TestListBrowsersUsesPersistedStateWithoutProbing(t *testing.T) { store := newMemoryStore() store.envs["account-a"] = hub.Env{Alias: "account-a", Name: "店铺一号", Gateway: "gw-1", ImageVersion: "148"} store.envs["account-b"] = hub.Env{Alias: "account-b", Name: "店铺二号", Gateway: "gw-1", ImageVersion: "148"} + store.bindings["account-a"] = hub.EnvironmentContext{ + Env: store.envs["account-a"], AccountID: "account-a", AccountStatus: "active", AuthorizationStatus: "authorized", + BindingID: "account-a", BindingVersion: 1, Exit: store.exits["exit-1"], RuntimeInstanceID: "runtime-1", RuntimeID: "persisted-container", + } gateway := &fakeGateway{ token: "unit-test-gateway-token", containers: []containerStatus{ - {ID: "id-1", Alias: "account-a", State: "running", Status: "Up", Endpoint: "http://creatorhub-browser-account-a:9222", BindingVersion: 1, NetworkExitID: "exit-1", NetworkID: "network-id", ProxyReady: true}, + {ID: "live-container", Alias: "account-a", State: "running", Status: "Up", Endpoint: "http://creatorhub-browser-account-a:9222", BindingVersion: 1, NetworkExitID: "exit-1", NetworkID: "network-id", ProxyReady: true}, }, } app := newTestApp(t, store, gateway) @@ -1558,17 +1540,20 @@ func TestListBrowsersMergesLiveGatewayState(t *testing.T) { for _, view := range views { byAlias[view.Alias] = view } - if byAlias["account-a"].State != "running" || byAlias["account-a"].ContainerID != "id-1" { - t.Fatalf("running container state must be merged: %#v", byAlias["account-a"]) + if view := byAlias["account-a"]; view.State != "running" || view.Status != "已记录运行实例" || view.ContainerID != "persisted-container" || view.Endpoint != "" { + t.Fatalf("list must expose persisted runtime data only: %#v", view) } if view := byAlias["account-a"]; view.AccountID != "account-a" || view.NetworkExitHealth != "healthy" || view.ScheduleStatus != "ready" || view.ScheduleBlockReason != "" { - t.Fatalf("binding readiness must be exposed without credentials: %#v", view) + t.Fatalf("binding readiness must be exposed without gateway access: %#v", view) } if byAlias["account-b"].State != "missing" { - t.Fatalf("env without container must report missing: %#v", byAlias["account-b"]) + t.Fatalf("env without a persisted runtime must report missing: %#v", byAlias["account-b"]) } - if runtime := store.bindings["account-a"].RuntimeID; runtime != "id-1" { - t.Fatalf("list reconciliation must heartbeat the running runtime, got %q", runtime) + if response = do(app, http.MethodGet, "/api/browsers/account-a", ""); response.Code != http.StatusOK { + t.Fatalf("browser detail returned %d: %s", response.Code, response.Body.String()) + } + if requests := gateway.recorded(); len(requests) != 0 { + t.Fatalf("browser list/detail performed live probes: %#v", requests) } } @@ -1581,14 +1566,13 @@ func TestListRestoresProxyAfterGatewayRestartBeforeHeartbeat(t *testing.T) { gateway := &fakeGateway{token: "unit-test-gateway-token", containers: []containerStatus{{ ID: "container-id", Alias: "account-a", State: "running", BindingVersion: 3, NetworkExitID: "exit-1", NetworkID: "network-id", ProxyReady: false, }}} - app := newTestApp(t, store, gateway) + _ = newTestApp(t, store, gateway) - response := do(app, http.MethodGet, "/api/browsers", "") - if response.Code != http.StatusOK { - t.Fatalf("gateway restart recovery failed: %d %s", response.Code, response.Body.String()) + if err := reconcileRuntimeLeases(context.Background(), store, fakeExitProbe{}, func(hub.NetworkExitAccess) (string, error) { return "", nil }); err != nil { + t.Fatalf("gateway restart recovery failed: %v", err) } if runtime := store.bindings["account-a"].RuntimeID; runtime != "container-id" { - t.Fatalf("runtime was activated before proxy recovery completed: %q", runtime) + t.Fatalf("runtime was not activated after proxy recovery: %q", runtime) } requests := gateway.recorded() if len(requests) != 2 || requests[0].path != "/v1/browsers" || requests[1].path != "/v1/browsers/account-a/proxy" { @@ -1608,10 +1592,10 @@ func TestGatewayRestartRebuildsWhenOriginalProxyPortCannotBeRestored(t *testing. gateway := &fakeGateway{token: "unit-test-gateway-token", failProxy: true, containers: []containerStatus{{ ID: "old-container", Alias: "account-a", State: "running", BindingVersion: 2, NetworkExitID: "exit-1", NetworkID: "network-old", }}} - app := newTestApp(t, store, gateway) + _ = newTestApp(t, store, gateway) - if response := do(app, http.MethodGet, "/api/browsers", ""); response.Code != http.StatusConflict { - t.Fatalf("lease-free running orphan was not fenced: %d %s", response.Code, response.Body.String()) + if err := reconcileRuntimeLeases(context.Background(), store, fakeExitProbe{}, func(hub.NetworkExitAccess) (string, error) { return "", nil }); !errors.Is(err, hub.ErrConflict) { + t.Fatalf("lease-free running orphan was not fenced: %v", err) } requests := gateway.recorded() if len(requests) != 3 || requests[1].path != "/v1/browsers/account-a/proxy" { @@ -1755,17 +1739,15 @@ func TestUpgradeBrowserUsesCommittedPostgresBinding(t *testing.T) { NetworkExitID: "stale-exit", NetworkID: "network-old", ProxyReady: true, }} gateway.mu.Unlock() - response = do(app, http.MethodGet, "/api/browsers", "") - if response.Code != http.StatusBadGateway { - t.Fatalf("PostgreSQL-backed reconcile delete failure returned %d: %s", response.Code, response.Body.String()) + if err := reconcileRuntimeLeases(ctx, store, fakeExitProbe{}, func(hub.NetworkExitAccess) (string, error) { return "", nil }); err == nil { + t.Fatal("PostgreSQL-backed reconcile delete failure unexpectedly succeeded") } released, err := store.GetEnvironmentContext(ctx, "account-a") if err != nil || released.RuntimeID != "" || released.BindingVersion != after.BindingVersion || !released.RuntimeCleanupPending { t.Fatalf("delete failure must release the real Store lease without changing binding: %#v err=%v", released, err) } - response = do(app, http.MethodGet, "/api/browsers", "") - if response.Code != http.StatusOK { - t.Fatalf("PostgreSQL-backed cleanup retry returned %d: %s", response.Code, response.Body.String()) + if err := reconcileRuntimeLeases(ctx, store, fakeExitProbe{}, func(hub.NetworkExitAccess) (string, error) { return "", nil }); err != nil { + t.Fatalf("PostgreSQL-backed cleanup retry failed: %v", err) } released, err = store.GetEnvironmentContext(ctx, "account-a") if err != nil || released.RuntimeCleanupPending { @@ -2326,9 +2308,8 @@ func TestPostgresCleanupPendingPersistsAndReconciles(t *testing.T) { assertControlPlaneDatabaseCount(t, fixture.db, `SELECT count(*) FROM environment_binding WHERE browser_env_alias = 'account-a' AND runtime_cleanup_pending`, 1) assertControlPlaneDatabaseCount(t, fixture.db, `SELECT count(*) FROM runtime_instance WHERE binding_id = 'account-a' AND released_at IS NULL`, 0) - response := do(app, http.MethodGet, "/api/browsers", "") - if response.Code != http.StatusOK { - t.Fatalf("list reconcile did not confirm cleanup: %d: %s", response.Code, response.Body.String()) + if err := reconcileRuntimeLeases(ctx, fixture.store, fakeExitProbe{}, func(hub.NetworkExitAccess) (string, error) { return "", nil }); err != nil { + t.Fatalf("reconcile did not confirm cleanup: %v", err) } after, err = fixture.store.GetEnvironmentContext(ctx, "account-a") if err != nil || after.RuntimeCleanupPending { @@ -2336,7 +2317,7 @@ func TestPostgresCleanupPendingPersistsAndReconciles(t *testing.T) { } setFixtureAccountStatus(t, fixture.databaseURL, "paused") - response = do(app, http.MethodPost, "/api/browsers/account-a/rebind", `{"network_exit_id":"`+fixture.exit.ID+`"}`) + response := do(app, http.MethodPost, "/api/browsers/account-a/rebind", `{"network_exit_id":"`+fixture.exit.ID+`"}`) if response.Code != http.StatusOK { t.Fatalf("rebind retry failed after cleanup confirmation: %d: %s", response.Code, response.Body.String()) } @@ -2500,7 +2481,7 @@ func TestRemoveGatewayRuntimeConvergesUnknownGeneration(t *testing.T) { store.envs["account-a"] = hub.Env{Alias: "account-a", Gateway: "gw-1", ImageVersion: "148"} environment := hub.EnvironmentContext{ Env: store.envs["account-a"], AccountID: "account-a", BindingID: "account-a", BindingVersion: 1, - Exit: store.exits["exit-1"], + Exit: store.exits["exit-1"], RuntimeCleanupPending: true, RuntimeCleanupBindingVersion: 1, RuntimeCleanupRuntimeID: missingRuntimeID, } @@ -2595,9 +2576,8 @@ func TestRestoreAndDiscardPreserveGenerationWhenCleanupMarkFails(t *testing.T) { }}} app := newTestApp(t, store, gateway) - response := do(app, http.MethodGet, "/api/browsers", "") - if response.Code != http.StatusInternalServerError { - t.Fatalf("failed cleanup mark returned %d: %s", response.Code, response.Body.String()) + if err := reconcileRuntimeLeases(context.Background(), store, fakeExitProbe{}, func(hub.NetworkExitAccess) (string, error) { return "", nil }); err == nil { + t.Fatal("failed cleanup mark unexpectedly succeeded") } after := store.bindings["account-a"] if test.commitKnown { @@ -2621,13 +2601,13 @@ func TestRestoreAndDiscardPreserveGenerationWhenCleanupMarkFails(t *testing.T) { store.cleanupPendingErr = nil store.cleanupPendingErrAfterMutation = false - if response = do(app, http.MethodGet, "/api/browsers", ""); response.Code != http.StatusOK { - t.Fatalf("list retry failed: %d: %s", response.Code, response.Body.String()) + if err := reconcileRuntimeLeases(context.Background(), store, fakeExitProbe{}, func(hub.NetworkExitAccess) (string, error) { return "", nil }); err != nil { + t.Fatalf("reconcile retry failed: %v", err) } if test.commitKnown { after = store.bindings["account-a"] } else { - if response = do(app, http.MethodPost, "/api/browsers/account-a/start", ""); response.Code != http.StatusNoContent { + if response := do(app, http.MethodPost, "/api/browsers/account-a/start", ""); response.Code != http.StatusNoContent { t.Fatalf("start retry failed: %d: %s", response.Code, response.Body.String()) } after = store.bindings["account-a"] @@ -2874,8 +2854,8 @@ func TestPostgresCleanupPendingTransactionRollbacks(t *testing.T) { setFixtureAccountStatus(t, fixture.databaseURL, "active") app := fiber.New() registerHubWithNetwork(app, fixture.store, fakeExitProbe{}, func(hub.NetworkExitAccess) (string, error) { return "", nil }) - if response := do(app, http.MethodGet, "/api/browsers", ""); response.Code != http.StatusOK { - t.Fatalf("list retry did not confirm cleanup: %d: %s", response.Code, response.Body.String()) + if err := reconcileRuntimeLeases(context.Background(), fixture.store, fakeExitProbe{}, func(hub.NetworkExitAccess) (string, error) { return "", nil }); err != nil { + t.Fatalf("reconcile retry did not confirm cleanup: %v", err) } if response := do(app, http.MethodPost, "/api/browsers/account-a/start", ""); response.Code != http.StatusNoContent { t.Fatalf("lifecycle retry did not rebuild runtime: %d: %s", response.Code, response.Body.String()) @@ -2983,12 +2963,8 @@ func TestPostgresCleanupCallChainsPreserveGeneration(t *testing.T) { } else { installCleanupTransitionFailure(t, ctx, fixture.db, `NOT OLD.runtime_cleanup_pending AND NEW.runtime_cleanup_pending`) } - app := fiber.New() - registerHubWithNetwork(app, store, fakeExitProbe{}, func(hub.NetworkExitAccess) (string, error) { return "", nil }) - - response := do(app, http.MethodGet, "/api/browsers", "") - if response.Code != http.StatusInternalServerError { - t.Fatalf("restore cleanup failure returned %d: %s", response.Code, response.Body.String()) + if err := reconcileRuntimeLeases(ctx, store, fakeExitProbe{}, func(hub.NetworkExitAccess) (string, error) { return "", nil }); err == nil { + t.Fatal("restore cleanup failure unexpectedly succeeded") } after, err := fixture.store.GetEnvironmentContext(ctx, "account-a") if test.commitUnknown { @@ -3023,13 +2999,13 @@ func TestPostgresCleanupCallChainsPreserveGeneration(t *testing.T) { if !test.commitUnknown { dropCleanupTransitionFailure(t, ctx, fixture.db) } - retryApp := fiber.New() - registerHubWithNetwork(retryApp, fixture.store, fakeExitProbe{}, func(hub.NetworkExitAccess) (string, error) { return "", nil }) - if response = do(retryApp, http.MethodGet, "/api/browsers", ""); response.Code != http.StatusOK { - t.Fatalf("restore list retry failed: %d: %s", response.Code, response.Body.String()) + if err := reconcileRuntimeLeases(ctx, fixture.store, fakeExitProbe{}, func(hub.NetworkExitAccess) (string, error) { return "", nil }); err != nil { + t.Fatalf("restore reconcile retry failed: %v", err) } if test.commitUnknown { - if response = do(retryApp, http.MethodPost, "/api/browsers/account-a/start", ""); response.Code != http.StatusNoContent { + retryApp := fiber.New() + registerHubWithNetwork(retryApp, fixture.store, fakeExitProbe{}, func(hub.NetworkExitAccess) (string, error) { return "", nil }) + if response := do(retryApp, http.MethodPost, "/api/browsers/account-a/start", ""); response.Code != http.StatusNoContent { t.Fatalf("restore start retry failed: %d: %s", response.Code, response.Body.String()) } } @@ -3176,10 +3152,8 @@ func TestPostgresCleanupCallChainsPreserveGeneration(t *testing.T) { if !test.commitUnknown { dropCleanupTransitionFailure(t, ctx, fixture.db) } - retryApp := fiber.New() - registerHubWithNetwork(retryApp, fixture.store, fakeExitProbe{failure: "proxy_auth_failed"}, func(hub.NetworkExitAccess) (string, error) { return "", nil }) - if response = do(retryApp, http.MethodGet, "/api/browsers", ""); response.Code != http.StatusOK { - t.Fatalf("discard retry returned %d: %s", response.Code, response.Body.String()) + if err := reconcileRuntimeLeases(ctx, fixture.store, fakeExitProbe{failure: "proxy_auth_failed"}, func(hub.NetworkExitAccess) (string, error) { return "", nil }); err != nil { + t.Fatalf("discard reconcile retry failed: %v", err) } after, err = fixture.store.GetEnvironmentContext(ctx, "account-a") if err != nil || after.RuntimeCleanupPending || after.RuntimeID != "" { @@ -3195,7 +3169,7 @@ func TestPostgresCleanupCallChainsPreserveGeneration(t *testing.T) { } } -func TestPostgresGatewayUnknownBlocksListAndCreateUntilRetry(t *testing.T) { +func TestPostgresGatewayUnknownDoesNotAffectListAndStillBlocksCreate(t *testing.T) { databaseURL := os.Getenv("CREATORHUB_POSTGRES_TEST_URL") if databaseURL == "" { t.Skip("set CREATORHUB_POSTGRES_TEST_URL to run PostgreSQL integration coverage") @@ -3225,20 +3199,16 @@ func TestPostgresGatewayUnknownBlocksListAndCreateUntilRetry(t *testing.T) { app := fiber.New() registerHubWithNetwork(app, fixture.store, fakeExitProbe{}, func(hub.NetworkExitAccess) (string, error) { return "", nil }) - if response := do(app, http.MethodGet, "/api/browsers", ""); response.Code != http.StatusBadGateway { - t.Fatalf("gateway unknown list returned %d: %s", response.Code, response.Body.String()) + if response := do(app, http.MethodGet, "/api/browsers", ""); response.Code != http.StatusOK { + t.Fatalf("gateway failure affected browser list: %d: %s", response.Code, response.Body.String()) } after, err := fixture.store.GetEnvironmentContext(ctx, "account-a") if err != nil || after.RuntimeCleanupPending || after.RuntimeID != "old-container" || len(fixture.gateway.containers) != 1 { - t.Fatalf("gateway unknown list changed the generation: after=%#v containers=%#v err=%v", after, fixture.gateway.containers, err) + t.Fatalf("browser list changed the generation: after=%#v containers=%#v err=%v", after, fixture.gateway.containers, err) } assertControlPlaneDatabaseCount(t, fixture.db, `SELECT count(*) FROM runtime_instance WHERE binding_id = 'account-a' AND released_at IS NULL`, 1) - if response := do(app, http.MethodGet, "/api/browsers", ""); response.Code != http.StatusOK { - t.Fatalf("gateway list retry failed: %d: %s", response.Code, response.Body.String()) - } - after, err = fixture.store.GetEnvironmentContext(ctx, "account-a") - if err != nil || after.RuntimeID != "old-container" || len(fixture.gateway.containers) != 1 { - t.Fatalf("gateway list retry ended inconsistently: after=%#v containers=%#v err=%v", after, fixture.gateway.containers, err) + if requests := fixture.gateway.recorded(); len(requests) != 0 { + t.Fatalf("browser list performed live gateway detection: %#v", requests) } }) @@ -3300,7 +3270,7 @@ func TestPostgresGatewayUnknownBlocksListAndCreateUntilRetry(t *testing.T) { } } -func TestPostgresStrictGatewayListBlocksLifecycleUntilRetry(t *testing.T) { +func TestPostgresStrictGatewayValidationBlocksLifecycleUntilRetry(t *testing.T) { databaseURL := os.Getenv("CREATORHUB_POSTGRES_TEST_URL") if databaseURL == "" { t.Skip("set CREATORHUB_POSTGRES_TEST_URL to run PostgreSQL integration coverage") @@ -3312,8 +3282,6 @@ func TestPostgresStrictGatewayListBlocksLifecycleUntilRetry(t *testing.T) { active bool wantStatus int }{ - {name: "list rejects top-level null", method: http.MethodGet, path: "/api/browsers", active: true, wantStatus: http.StatusOK, - configure: func(gateway *fakeGateway, count int) { gateway.invalidList, gateway.invalidListBody = count, `null` }}, {name: "create rejects null element", method: http.MethodPost, path: "/api/browsers", action: "create", active: true, wantStatus: http.StatusOK, successReason: "environment_reused", configure: func(gateway *fakeGateway, count int) { gateway.invalidList, gateway.invalidListBody = count, `[null]` }}, @@ -3363,17 +3331,17 @@ func TestPostgresStrictGatewayListBlocksLifecycleUntilRetry(t *testing.T) { response := do(app, test.method, test.path, body) if response.Code != http.StatusBadGateway { - t.Fatalf("strict list failure returned %d: %s", response.Code, response.Body.String()) + t.Fatalf("strict gateway validation returned %d: %s", response.Code, response.Body.String()) } after, err := fixture.store.GetEnvironmentContext(ctx, "account-a") if err != nil || after.BindingVersion != fixture.bound.BindingVersion || after.Exit.ID != fixture.bound.Exit.ID || after.RuntimeCleanupPending || after.RuntimeID != fixture.bound.RuntimeID || len(fixture.gateway.containers) != 1 { - t.Fatalf("strict list failure changed the generation: before=%#v after=%#v containers=%#v err=%v", + t.Fatalf("strict gateway validation changed the generation: before=%#v after=%#v containers=%#v err=%v", fixture.bound, after, fixture.gateway.containers, err) } for _, request := range fixture.gateway.recorded() { if request.method != http.MethodGet { - t.Fatalf("strict list failure advanced the lifecycle: %#v", fixture.gateway.recorded()) + t.Fatalf("strict gateway validation advanced the lifecycle: %#v", fixture.gateway.recorded()) } } activeCount := 0 @@ -3389,13 +3357,13 @@ func TestPostgresStrictGatewayListBlocksLifecycleUntilRetry(t *testing.T) { t.Fatal(err) } if outcome != "unknown" || reason != "gateway_result_unknown" { - t.Fatalf("strict list audit mismatch: outcome=%s reason=%s", outcome, reason) + t.Fatalf("strict gateway audit mismatch: outcome=%s reason=%s", outcome, reason) } } response = do(app, test.method, test.path, body) if response.Code != test.wantStatus { - t.Fatalf("strict list retry returned %d: %s", response.Code, response.Body.String()) + t.Fatalf("strict gateway retry returned %d: %s", response.Code, response.Body.String()) } after, err = fixture.store.GetEnvironmentContext(ctx, "account-a") fixture.gateway.mu.Lock() @@ -3410,7 +3378,7 @@ func TestPostgresStrictGatewayListBlocksLifecycleUntilRetry(t *testing.T) { validRuntime = after.RuntimeID == "" && len(containers) == 0 } if err != nil || after.BindingVersion != wantVersion || !validRuntime { - t.Fatalf("strict list retry ended inconsistently: after=%#v containers=%#v err=%v", after, containers, err) + t.Fatalf("strict gateway retry ended inconsistently: after=%#v containers=%#v err=%v", after, containers, err) } activeAfter := 1 if test.action == "rebind" { @@ -3425,7 +3393,7 @@ func TestPostgresStrictGatewayListBlocksLifecycleUntilRetry(t *testing.T) { t.Fatal(err) } if outcome != "succeeded" || reason != test.successReason { - t.Fatalf("strict list retry audit mismatch: outcome=%s reason=%s", outcome, reason) + t.Fatalf("strict gateway retry audit mismatch: outcome=%s reason=%s", outcome, reason) } } }) @@ -3595,11 +3563,8 @@ func TestPostgresReconcileFinishedAuditUsesActivatedRuntime(t *testing.T) { ID: "stale-container", Alias: "account-a", State: "running", ProxyReady: true, BindingVersion: fixture.bound.BindingVersion, NetworkExitID: "stale-exit", NetworkID: "network-old", }} - app := fiber.New() - registerHubWithNetwork(app, fixture.store, fakeExitProbe{}, func(hub.NetworkExitAccess) (string, error) { return "", nil }) - response := do(app, http.MethodGet, "/api/browsers", "") - if response.Code != http.StatusOK { - t.Fatalf("reconcile returned %d: %s", response.Code, response.Body.String()) + if err := reconcileRuntimeLeases(context.Background(), fixture.store, fakeExitProbe{}, func(hub.NetworkExitAccess) (string, error) { return "", nil }); err != nil { + t.Fatalf("reconcile failed: %v", err) } after, err := fixture.store.GetEnvironmentContext(context.Background(), "account-a") if err != nil || after.RuntimeInstanceID == "" || after.RuntimeID != "container-id" { @@ -3655,16 +3620,13 @@ func TestPostgresNonRunnableReconcileAuditsRuntimeRelease(t *testing.T) { if releaseFailure { store = failingRuntimeReleaseStore{Store: fixture.store, err: errors.New("release unavailable")} } - app := fiber.New() - registerHubWithNetwork(app, store, fakeExitProbe{}, func(hub.NetworkExitAccess) (string, error) { return "", nil }) - response := do(app, http.MethodGet, "/api/browsers", "") - wantStatus := http.StatusOK + err = reconcileRuntimeLeases(ctx, store, fakeExitProbe{}, func(hub.NetworkExitAccess) (string, error) { return "", nil }) wantOutcome, wantReason := "succeeded", "runtime_released" if releaseFailure { - wantStatus, wantOutcome, wantReason = http.StatusInternalServerError, "failed", "runtime_release_failed" + wantOutcome, wantReason = "failed", "runtime_release_failed" } - if response.Code != wantStatus { - t.Fatalf("reconcile returned %d, want %d: %s", response.Code, wantStatus, response.Body.String()) + if (err != nil) != releaseFailure { + t.Fatalf("reconcile error=%v, releaseFailure=%v", err, releaseFailure) } after, err := fixture.store.GetEnvironmentContext(ctx, "account-a") if err != nil || (after.RuntimeInstanceID != "") == !releaseFailure { @@ -3767,20 +3729,31 @@ func TestPostgresImageDisableWaitsForEveryImageConsumer(t *testing.T) { err error } lifecycleDone := make(chan result, 1) - go func() { - request, err := http.NewRequest(method, server.URL+path, strings.NewReader(body)) - if err == nil { - request.Header.Set("Content-Type", "application/json") - var response *http.Response - response, err = server.Client().Do(request) - if err == nil { - defer response.Body.Close() - lifecycleDone <- result{status: response.StatusCode} + if action == "reconcile" { + go func() { + err := reconcileRuntimeLeases(context.Background(), fixture.store, probe, func(hub.NetworkExitAccess) (string, error) { return "", nil }) + if err != nil { + lifecycleDone <- result{err: err} return } - } - lifecycleDone <- result{err: err} - }() + lifecycleDone <- result{status: expected} + }() + } else { + go func() { + request, err := http.NewRequest(method, server.URL+path, strings.NewReader(body)) + if err == nil { + request.Header.Set("Content-Type", "application/json") + var response *http.Response + response, err = server.Client().Do(request) + if err == nil { + defer response.Body.Close() + lifecycleDone <- result{status: response.StatusCode} + return + } + } + lifecycleDone <- result{err: err} + }() + } select { case <-lifecycleStarted: case result := <-lifecycleDone: @@ -3909,7 +3882,7 @@ func TestImageDisableWaitsForUpgradeCommit(t *testing.T) { } } -func TestListAndHeartbeatWaitForUpgradeCoordination(t *testing.T) { +func TestListDoesNotWaitForUpgradeWhileHeartbeatDoes(t *testing.T) { releaseCreate := make(chan struct{}) gateway := &fakeGateway{ token: "unit-test-gateway-token", @@ -3975,9 +3948,17 @@ func TestListAndHeartbeatWaitForUpgradeCoordination(t *testing.T) { <-heartbeatStarted select { case result := <-listDone: - t.Fatalf("list reconciled a stale snapshot during upgrade: %#v", result) + if result.err != nil || result.status != http.StatusOK { + t.Fatalf("list failed while upgrade was in progress: %#v", result) + } case err := <-heartbeatDone: - t.Fatalf("heartbeat reconciled a stale snapshot during upgrade: %v", err) + t.Fatalf("heartbeat ignored upgrade coordination: %v", err) + case <-time.After(time.Second): + t.Fatal("list did not return while upgrade was in progress") + } + select { + case err := <-heartbeatDone: + t.Fatalf("heartbeat finished before the upgrade released its resources: %v", err) case <-time.After(time.Second): } @@ -3985,9 +3966,6 @@ func TestListAndHeartbeatWaitForUpgradeCoordination(t *testing.T) { if result := <-upgradeDone; result.err != nil || result.status != http.StatusNoContent { t.Fatalf("upgrade failed: %#v", result) } - if result := <-listDone; result.err != nil || result.status != http.StatusOK { - t.Fatalf("coordinated list failed: %#v", result) - } if err := <-heartbeatDone; err != nil { t.Fatalf("coordinated heartbeat failed: %v", err) } @@ -4056,11 +4034,10 @@ func TestReconcileStopsRuntimeForPausedOrRevokedAccount(t *testing.T) { gateway := &fakeGateway{token: "unit-test-gateway-token", containers: []containerStatus{{ ID: "old-container", Alias: "account-a", State: "running", ProxyReady: true, BindingVersion: 1, NetworkExitID: "exit-1", }}} - app := newTestApp(t, store, gateway) + _ = newTestApp(t, store, gateway) - response := do(app, http.MethodGet, "/api/browsers", "") - if response.Code != http.StatusOK { - t.Fatalf("reconcile returned %d: %s", response.Code, response.Body.String()) + if err := reconcileRuntimeLeases(context.Background(), store, fakeExitProbe{}, func(hub.NetworkExitAccess) (string, error) { return "", nil }); err != nil { + t.Fatalf("reconcile failed: %v", err) } if runtime := store.bindings["account-a"].RuntimeID; runtime != "" { t.Fatalf("non-runnable account retained runtime %q", runtime) @@ -4183,11 +4160,10 @@ func TestReconcileFinishedAuditUsesRebuiltRuntime(t *testing.T) { gateway := &fakeGateway{token: "unit-test-gateway-token", containers: []containerStatus{{ ID: "old-container", Alias: "account-a", State: "running", ProxyReady: true, BindingVersion: 2, NetworkExitID: "exit-1", NetworkID: "network-old", }}} - app := newTestApp(t, store, gateway) + _ = newTestApp(t, store, gateway) - response := do(app, http.MethodGet, "/api/browsers", "") - if response.Code != http.StatusOK { - t.Fatalf("reconcile returned %d: %s", response.Code, response.Body.String()) + if err := reconcileRuntimeLeases(context.Background(), store, fakeExitProbe{}, func(hub.NetworkExitAccess) (string, error) { return "", nil }); err != nil { + t.Fatalf("reconcile failed: %v", err) } if len(store.actions) != 2 || store.actions[1].Outcome != "succeeded" || store.actions[1].RuntimeInstanceID != "runtime-instance" || store.actions[1].BindingVersion != 2 || store.actions[1].NetworkExitID != "exit-2" { @@ -4210,12 +4186,9 @@ func TestReconcileContextRefreshFailureClearsAllAuditCorrelation(t *testing.T) { defer server.Close() store.gateways["gw-1"] = hub.Gateway{Name: "gw-1", Endpoint: server.URL, Token: gateway.token} failing := &failContextRefreshStore{hubStore: store} - app := fiber.New() - registerHubWithNetwork(app, failing, fakeExitProbe{}, func(hub.NetworkExitAccess) (string, error) { return "", nil }) - response := do(app, http.MethodGet, "/api/browsers", "") - if response.Code != http.StatusInternalServerError { - t.Fatalf("context refresh failure returned %d: %s", response.Code, response.Body.String()) + if err := reconcileRuntimeLeases(context.Background(), failing, fakeExitProbe{}, func(hub.NetworkExitAccess) (string, error) { return "", nil }); err == nil { + t.Fatal("context refresh failure unexpectedly succeeded") } finished := store.actions[len(store.actions)-1] if finished.RuntimeInstanceID != "" || finished.BindingVersion != 0 || finished.NetworkExitID != "" { @@ -4252,7 +4225,7 @@ func TestStoppedReconcileReportsRuntimeReleaseFailure(t *testing.T) { } } -func TestListReconcileAuditsRuntimeReleaseFailure(t *testing.T) { +func TestReconcileAuditsRuntimeReleaseFailure(t *testing.T) { store := newMemoryStore() store.envs["account-a"] = hub.Env{Alias: "account-a", Name: "甲", Gateway: "gw-1", ImageVersion: "148", Fingerprint: hub.Fingerprint{Seed: 1}} store.bindings["account-a"] = hub.EnvironmentContext{ @@ -4263,11 +4236,10 @@ func TestListReconcileAuditsRuntimeReleaseFailure(t *testing.T) { gateway := &fakeGateway{token: "unit-test-gateway-token", containers: []containerStatus{{ ID: "container-id", Alias: "account-a", State: "exited", BindingVersion: 1, NetworkExitID: "exit-1", }}} - app := newTestApp(t, store, gateway) + _ = newTestApp(t, store, gateway) - response := do(app, http.MethodGet, "/api/browsers", "") - if response.Code != http.StatusInternalServerError { - t.Fatalf("expected release failure, got %d: %s", response.Code, response.Body.String()) + if err := reconcileRuntimeLeases(context.Background(), store, fakeExitProbe{}, func(hub.NetworkExitAccess) (string, error) { return "", nil }); err == nil { + t.Fatal("expected release failure") } if len(store.actions) != 2 || store.actions[0].Action != "reconcile" || store.actions[1].Outcome != "failed" || store.actions[1].ReasonCode != "runtime_release_failed" { @@ -4346,12 +4318,12 @@ func TestRuntimeReuseRechecksHealthAndDiscardsFailedExit(t *testing.T) { gateway := &fakeGateway{token: "unit-test-gateway-token", containers: []containerStatus{{ ID: "container-id", Alias: "account-a", State: "running", BindingVersion: 1, NetworkExitID: "exit-1", ProxyReady: true, }}} - app := newTestAppWithNetwork(t, store, gateway, fakeExitProbe{failure: "exit_auth_failed"}, + probe := fakeExitProbe{failure: "exit_auth_failed"} + _ = newTestAppWithNetwork(t, store, gateway, probe, func(hub.NetworkExitAccess) (string, error) { return "", nil }) - response := do(app, http.MethodGet, "/api/browsers", "") - if response.Code != http.StatusOK { - t.Fatalf("unhealthy runtime reconciliation failed: %d: %s", response.Code, response.Body.String()) + if err := reconcileRuntimeLeases(context.Background(), store, probe, func(hub.NetworkExitAccess) (string, error) { return "", nil }); err != nil { + t.Fatalf("unhealthy runtime reconciliation failed: %v", err) } if runtime := store.bindings["account-a"].RuntimeID; runtime != "" { t.Fatalf("failed exit remained active: %q", runtime) @@ -4372,11 +4344,10 @@ func TestReconcileDeleteFailureReleasesLeaseAndAuditsUnknown(t *testing.T) { gateway := &fakeGateway{token: "unit-test-gateway-token", failDelete: 1, containers: []containerStatus{{ ID: "stale-container", Alias: "account-a", State: "running", BindingVersion: 2, NetworkExitID: "stale-exit", NetworkID: "network-old", ProxyReady: true, }}} - app := newTestApp(t, store, gateway) + _ = newTestApp(t, store, gateway) - response := do(app, http.MethodGet, "/api/browsers", "") - if response.Code != http.StatusBadGateway { - t.Fatalf("delete failure must remain unknown, got %d: %s", response.Code, response.Body.String()) + if err := reconcileRuntimeLeases(context.Background(), store, fakeExitProbe{}, func(hub.NetworkExitAccess) (string, error) { return "", nil }); err == nil { + t.Fatal("delete failure unexpectedly succeeded") } if runtime := store.bindings["account-a"].RuntimeID; runtime != "" { t.Fatalf("delete failure retained DB runtime lease: %q", runtime) @@ -4653,7 +4624,6 @@ func TestCleanupPendingBlocksEveryLifecyclePath(t *testing.T) { for _, test := range []struct { name, method, path, body string }{ - {name: "list reconcile", method: http.MethodGet, path: "/api/browsers"}, {name: "create reuse", method: http.MethodPost, path: "/api/browsers", body: `{"alias":"account-a","name":"甲","gateway":"gw-1","image_version":"148","fingerprint":{"seed":1},"account_id":"account-a","network_exit_id":"exit-1"}`}, {name: "start", method: http.MethodPost, path: "/api/browsers/account-a/start"}, {name: "upgrade", method: http.MethodPost, path: "/api/browsers/account-a/upgrade", body: `{"version":"149"}`}, diff --git a/web/src/BrowsersPage.jsx b/web/src/BrowsersPage.jsx index 4cc9d40..9900003 100644 --- a/web/src/BrowsersPage.jsx +++ b/web/src/BrowsersPage.jsx @@ -324,10 +324,7 @@ export function BrowserList() { const [busy, setBusy] = useState(""); const [actionError, setActionError] = useState(""); const [upgradeTarget, setUpgradeTarget] = useState(null); - const { result, query } = useList({ - resource: "browsers", - queryOptions: { refetchInterval: 3000 }, - }); + const { result, query } = useList({ resource: "browsers" }); const error = query.error; const isPending = query.isPending; const runtimes = result.data ?? []; diff --git a/web/src/BrowsersPage.test.jsx b/web/src/BrowsersPage.test.jsx index 6d8c23b..3d2f504 100644 --- a/web/src/BrowsersPage.test.jsx +++ b/web/src/BrowsersPage.test.jsx @@ -65,6 +65,14 @@ describe('BrowserList', () => { expect(screen.getAllByText('CDP')).toHaveLength(1) }) + it('does not poll the browser list', async () => { + const setInterval = vi.spyOn(globalThis, 'setInterval') + renderList(provider()) + + await screen.findAllByText('店铺一号') + expect(setInterval.mock.calls.some(([, delay]) => delay === 3000)).toBe(false) + }) + it('shows unknown states with their raw value as secondary evidence', async () => { const unknownRuntime = { ...runtimes[0], state: 'dead' } renderList(provider({ getList: vi.fn().mockResolvedValue({ data: [unknownRuntime], total: 1 }) })) diff --git a/web/src/GatewaysPage.jsx b/web/src/GatewaysPage.jsx index 2f07751..f99d97f 100644 --- a/web/src/GatewaysPage.jsx +++ b/web/src/GatewaysPage.jsx @@ -9,36 +9,12 @@ import { 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 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); return ( @@ -321,54 +297,30 @@ export function GatewayList() { columns={[ { header: "名称", - width: "16%", + width: "20%", render: (gateway) => ( {gateway.name} ), }, { header: "Endpoint", - width: "28%", + width: "34%", render: (gateway) => ( {gateway.endpoint} ), }, - { - header: "连通性", - width: "9%", - render: (gateway) => ( - - ), - }, - { - header: "健康", - width: "9%", - render: (gateway) => ( - - ), - }, { header: "令牌", - width: "26%", + width: "30%", render: (gateway) => ( ), }, { header: "操作", - width: "12%", + width: "16%", align: "right", render: (gateway) => (
diff --git a/web/src/GatewaysPage.test.jsx b/web/src/GatewaysPage.test.jsx index 266c847..a7c3150 100644 --- a/web/src/GatewaysPage.test.jsx +++ b/web/src/GatewaysPage.test.jsx @@ -23,18 +23,12 @@ const gateways = [ name: "gw-1", endpoint: "http://docker-gateway:8081", token: "unit-test-gateway-token", - connectivity: "online", - health: "healthy", }, { id: "gw-2", name: "gw-2", endpoint: "http://gw2:8081", token: "offline-token-123456", - connectivity: "offline", - connectivity_reason: "connection refused", - health: "unhealthy", - health_reason: "gateway unreachable", }, ]; @@ -223,19 +217,14 @@ describe("GatewayList", () => { ); }); - it("shows connectivity and health with reasons for failing gateways", async () => { + it("does not render live gateway status in the list", async () => { renderGateways(provider()); - const online = await screen.findByText("在线"); - expect((await screen.findAllByText("健康")).length).toBeGreaterThan(0); - const offline = screen.getByText("离线"); - const unhealthy = screen.getByText("异常"); - expect(offline.closest("[title]")?.getAttribute("title")).toBe( - "connection refused", - ); - expect(unhealthy.closest("[title]")?.getAttribute("title")).toBe( - "gateway unreachable", - ); - expect(online.closest("[title]")).toBe(null); + const table = await screen.findByRole("table"); + expect(within(table).queryByText("连通性")).toBeNull(); + expect(within(table).queryByText("健康")).toBeNull(); + expect(within(table).queryByText("在线")).toBeNull(); + expect(within(table).queryByText("离线")).toBeNull(); + expect(within(table).queryByText("异常")).toBeNull(); }); });