fix: 列表详情页取消状态检测

This commit is contained in:
2026-09-11 09:35:28 +08:00
parent 7ce5fbd6d2
commit 1fbf126b21
6 changed files with 157 additions and 315 deletions
+8 -82
View File
@@ -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)
+129 -159
View File
@@ -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"}`},
+1 -4
View File
@@ -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 ?? [];
+8
View File
@@ -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 }) }))
+4 -52
View File
@@ -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 (
<span title={reason || undefined}>
<StatusPill
tone={tone}
label={known ? labels[value] : "未知"}
sub={known ? undefined : value}
/>
</span>
);
}
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) => (
<span className="font-semibold">{gateway.name}</span>
),
},
{
header: "Endpoint",
width: "28%",
width: "34%",
render: (gateway) => (
<code className="anywhere font-mono text-[13px]">
{gateway.endpoint}
</code>
),
},
{
header: "连通性",
width: "9%",
render: (gateway) => (
<GatewayStatePill
value={gateway.connectivity}
reason={gateway.connectivity_reason}
labels={connectivityLabels}
healthyValue="online"
/>
),
},
{
header: "健康",
width: "9%",
render: (gateway) => (
<GatewayStatePill
value={gateway.health}
reason={gateway.health_reason}
labels={healthLabels}
healthyValue="healthy"
/>
),
},
{
header: "令牌",
width: "26%",
width: "30%",
render: (gateway) => (
<TokenCell token={gateway.token} name={gateway.name} />
),
},
{
header: "操作",
width: "12%",
width: "16%",
align: "right",
render: (gateway) => (
<div className="flex items-center justify-end gap-1">
+7 -18
View File
@@ -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();
});
});