diff --git a/internal/controlplane/api/gateway_health.go b/internal/controlplane/api/gateway_health.go new file mode 100644 index 0000000..296986d --- /dev/null +++ b/internal/controlplane/api/gateway_health.go @@ -0,0 +1,72 @@ +package api + +import ( + "context" + "net/http" + "sync" + "time" + + hub "git.ipao.vip/rogee/creator-hub/internal/environment" + "github.com/sirupsen/logrus" +) + +const gatewayHealthProbeTimeout = 5 * time.Second + +// gatewayHealthStore 是网关健康检查所需的存储能力;生产实现为 *hub.Store,测试使用内存桩。 +type gatewayHealthStore interface { + ListGateways(ctx context.Context) ([]hub.Gateway, error) + RecordGatewayCheck(ctx context.Context, name, status, reason string) (hub.Gateway, error) +} + +// classifyGatewayHealth 将一次 /healthz 探活结果归一为持久化状态与原因。 +// 连接失败或 5xx 判为 unhealthy;其余(含 401 令牌不匹配)说明进程可达,判为 healthy +// 并把异常状态写进 reason 供排查。 +func classifyGatewayHealth(status int, callErr error) (string, string) { + if callErr != nil { + reason := callErr.Error() + if len(reason) > 300 { + reason = reason[:300] + } + return "unhealthy", reason + } + if status >= 500 { + return "unhealthy", "gateway returned status " + http.StatusText(status) + } + if status >= 400 { + return "healthy", "gateway responded with status " + http.StatusText(status) + } + return "healthy", "" +} + +// RecordGatewayHealthChecks 执行一轮网关健康探活并落库;供 workers 定时任务与测试调用。 +func RecordGatewayHealthChecks(ctx context.Context, store *hub.Store) error { + return recordGatewayHealthChecks(ctx, store) +} + +// recordGatewayHealthChecks 对全部注册网关并发探活一次,每个网关只覆盖写入最近一次结果。 +// 单个网关落库失败不阻断其余网关,仅记日志(下一轮探活会重试)。 +func recordGatewayHealthChecks(ctx context.Context, store gatewayHealthStore) error { + gateways, err := store.ListGateways(ctx) + if err != nil { + return err + } + var wg sync.WaitGroup + for _, gateway := range gateways { + wg.Add(1) + go func() { + defer wg.Done() + status, _, callErr := gatewayCall(ctx, gateway, http.MethodGet, "/healthz", nil, gatewayHealthProbeTimeout) + healthStatus, reason := classifyGatewayHealth(status, callErr) + if _, recordErr := store.RecordGatewayCheck(ctx, gateway.Name, healthStatus, reason); recordErr != nil { + logrus.WithFields(logrus.Fields{ + "service": "control-plane", + "event_type": "gateway_health_check", + "gateway": gateway.Name, + "probed": healthStatus, + }).WithError(recordErr).Warn("gateway health check result was not persisted") + } + }() + } + wg.Wait() + return nil +} diff --git a/internal/controlplane/api/gateway_health_test.go b/internal/controlplane/api/gateway_health_test.go new file mode 100644 index 0000000..099aa5f --- /dev/null +++ b/internal/controlplane/api/gateway_health_test.go @@ -0,0 +1,122 @@ +package api + +import ( + "context" + "errors" + "net/http" + "net/http/httptest" + "sync" + "testing" + "time" + + hub "git.ipao.vip/rogee/creator-hub/internal/environment" +) + +type recordedGatewayCheck struct { + name string + status string + reason string +} + +// healthMemoryStore 在 memoryStore 之上记录 RecordGatewayCheck 调用,供健康检查断言。 +type healthMemoryStore struct { + *memoryStore + mu sync.Mutex + checks []recordedGatewayCheck +} + +func (s *healthMemoryStore) RecordGatewayCheck(_ context.Context, name, status, reason string) (hub.Gateway, error) { + s.mu.Lock() + defer s.mu.Unlock() + s.checks = append(s.checks, recordedGatewayCheck{name: name, status: status, reason: reason}) + return hub.Gateway{Name: name, HealthStatus: status, LastCheckReason: reason}, nil +} + +func TestClassifyGatewayHealth(t *testing.T) { + for _, test := range []struct { + name string + status int + callErr error + wantStatus string + wantReasonHad bool // 是否应携带非空 reason + }{ + {name: "204 healthy without reason", status: http.StatusNoContent, wantStatus: "healthy", wantReasonHad: false}, + {name: "200 healthy without reason", status: http.StatusOK, wantStatus: "healthy", wantReasonHad: false}, + {name: "401 reachable but unauthorized", status: http.StatusUnauthorized, wantStatus: "healthy", wantReasonHad: true}, + {name: "404 reachable but misrouted", status: http.StatusNotFound, wantStatus: "healthy", wantReasonHad: true}, + {name: "503 unhealthy", status: http.StatusServiceUnavailable, wantStatus: "unhealthy", wantReasonHad: true}, + {name: "connection error unhealthy", callErr: errors.New(`Get "http://gw:8081/healthz": dial tcp: connection refused`), wantStatus: "unhealthy", wantReasonHad: true}, + } { + t.Run(test.name, func(t *testing.T) { + status, reason := classifyGatewayHealth(test.status, test.callErr) + if status != test.wantStatus { + t.Fatalf("classify status = %q, want %q (reason=%q)", status, test.wantStatus, reason) + } + if test.wantReasonHad != (reason != "") { + t.Fatalf("classify reason = %q, wantReasonHad=%v", reason, test.wantReasonHad) + } + }) + } +} + +func TestClassifyGatewayHealthTruncatesReason(t *testing.T) { + long := errors.New(string(make([]byte, 1024))) + _, reason := classifyGatewayHealth(0, long) + if len(reason) != 300 { + t.Fatalf("reason length = %d, want 300", len(reason)) + } +} + +func TestRecordGatewayHealthChecksPersistsLatestResult(t *testing.T) { + reachable := &fakeGateway{token: "token-a"} + reachableServer := httptest.NewServer(reachable.handler(t)) + t.Cleanup(reachableServer.Close) + + closedServer := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {})) + closedServer.Close() // 端口已释放,保证连接失败 + + store := &healthMemoryStore{memoryStore: newMemoryStore()} + store.gateways["gw-live"] = hub.Gateway{Name: "gw-live", Endpoint: reachableServer.URL, Token: reachable.token} + store.gateways["gw-down"] = hub.Gateway{Name: "gw-down", Endpoint: closedServer.URL, Token: "unused-token"} + + if err := recordGatewayHealthChecks(context.Background(), store); err != nil { + t.Fatalf("record gateway health checks returned error: %v", err) + } + + byName := map[string]recordedGatewayCheck{} + for _, check := range store.checks { + byName[check.name] = check + } + if len(store.checks) != 2 { + t.Fatalf("expected one check per gateway, got %#v", store.checks) + } + if byName["gw-live"].status != "healthy" { + t.Fatalf("reachable gateway status = %q, want healthy", byName["gw-live"].status) + } + if byName["gw-live"].reason != "" { + t.Fatalf("reachable gateway reason = %q, want empty", byName["gw-live"].reason) + } + if byName["gw-down"].status != "unhealthy" || byName["gw-down"].reason == "" { + t.Fatalf("unreachable gateway = %#v, want unhealthy with reason", byName["gw-down"]) + } + if requests := reachable.recorded(); len(requests) != 1 || requests[0].path != "/healthz" { + t.Fatalf("reachable gateway probes = %#v, want exactly one /healthz", requests) + } +} + +func TestRecordGatewayHealthChecksEmptyListIsNoop(t *testing.T) { + store := &healthMemoryStore{memoryStore: newMemoryStore()} + if err := recordGatewayHealthChecks(context.Background(), store); err != nil { + t.Fatalf("empty list returned error: %v", err) + } + if len(store.checks) != 0 { + t.Fatalf("empty list recorded checks: %#v", store.checks) + } +} + +// 编译期约束:探活超时常量必须明显小于调度间隔,避免探活互相堆叠。 +func TestGatewayHealthProbeTimeoutBounded(t *testing.T) { + if gatewayHealthProbeTimeout > 10*time.Second { + t.Fatalf("probe timeout %v must stay below scheduling cadence", gatewayHealthProbeTimeout) + } +} diff --git a/internal/controlplane/api/hub_test.go b/internal/controlplane/api/hub_test.go index f481da5..6c0950a 100644 --- a/internal/controlplane/api/hub_test.go +++ b/internal/controlplane/api/hub_test.go @@ -573,6 +573,9 @@ func (probe *sequenceExitProbe) Check(context.Context, hub.NetworkExitAccess) (h func (g *fakeGateway) handler(t *testing.T) http.Handler { return http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) { if request.Method == http.MethodGet && request.URL.Path == "/healthz" { + g.mu.Lock() + g.requests = append(g.requests, recordedRequest{method: request.Method, path: request.URL.Path}) + g.mu.Unlock() response.WriteHeader(http.StatusNoContent) return } @@ -829,12 +832,13 @@ func TestUpdateGatewayRenamesAndPreservesReferences(t *testing.T) { } } -func TestListGatewaysReturnsStoredRecordsWithoutProbing(t *testing.T) { +func TestListGatewaysReturnsStoredHealthWithoutProbing(t *testing.T) { store := newMemoryStore() gateway := &fakeGateway{token: "unit-test-gateway-token"} server := httptest.NewServer(gateway.handler(t)) t.Cleanup(server.Close) - stored := hub.Gateway{Name: "gw-1", Endpoint: server.URL, Token: gateway.token} + stored := hub.Gateway{Name: "gw-1", Endpoint: server.URL, Token: gateway.token, + HealthStatus: "healthy", LastCheckReason: "", LastCheckedAt: nil} store.gateways[stored.Name] = stored app := fiber.New() registerHubWithNetwork(app, store, fakeExitProbe{}, func(hub.NetworkExitAccess) (string, error) { return "", nil }) @@ -847,8 +851,8 @@ func TestListGatewaysReturnsStoredRecordsWithoutProbing(t *testing.T) { 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) } - 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 !strings.Contains(response.Body.String(), `"health_status":"healthy"`) { + t.Fatalf("gateway list should return persisted health status: %s", response.Body.String()) } if requests := gateway.recorded(); len(requests) != 0 { t.Fatalf("gateway list performed live probes: %#v", requests) diff --git a/internal/controlplane/app/app.go b/internal/controlplane/app/app.go index b0a061d..63a860d 100644 --- a/internal/controlplane/app/app.go +++ b/internal/controlplane/app/app.go @@ -97,13 +97,21 @@ func newCommand() *cobra.Command { defer close(creatorScheduleDone) workers.RunCreatorScheduler(creatorScheduleContext, creatorStore, phaseAStore, hubStore) }() + gatewayHealthContext, stopGatewayHealth := context.WithCancel(command.Context()) + gatewayHealthDone := make(chan struct{}) + go func() { + defer close(gatewayHealthDone) + workers.RunGatewayHealthCheck(gatewayHealthContext, hubStore) + }() listenErr := newHandler(cfg.webDir, cfg.username, cfg.password, phaseAStore, hubStore, credentials, creatorStore).Listen(cfg.listenAddr, fiber.ListenConfig{ GracefulContext: command.Context(), DisableStartupMessage: true, }) stopCreatorScheduler() + stopGatewayHealth() stopHeartbeat() <-creatorScheduleDone + <-gatewayHealthDone <-heartbeatDone return listenErr }, diff --git a/internal/controlplane/workers/runtime.go b/internal/controlplane/workers/runtime.go index a3aec7a..86a9ac8 100644 --- a/internal/controlplane/workers/runtime.go +++ b/internal/controlplane/workers/runtime.go @@ -29,3 +29,19 @@ func RunRuntimeLeaseHeartbeat(ctx context.Context, store *environment.Store) { func RunCreatorScheduler(ctx context.Context, store *creatorDomain.Store, accountStore *account.Store, environmentStore *environment.Store) { api.RunCreatorScheduler(ctx, store, accountStore, environmentStore) } + +// RunGatewayHealthCheck 每 30 秒对全部注册网关探活一次,只保留最近一次结果。 +func RunGatewayHealthCheck(ctx context.Context, store *environment.Store) { + ticker := time.NewTicker(30 * time.Second) + defer ticker.Stop() + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + if err := api.RecordGatewayHealthChecks(ctx, store); err != nil && ctx.Err() == nil { + logrus.WithField("service", "control-plane").WithError(err).Warn("gateway health check failed") + } + } + } +} diff --git a/internal/environment/migration043_probe_test.go b/internal/environment/migration043_probe_test.go index 994ea6d..2dff345 100644 --- a/internal/environment/migration043_probe_test.go +++ b/internal/environment/migration043_probe_test.go @@ -70,6 +70,6 @@ func TestMigration043ConsolidatedSchemaShape(t *testing.T) { // audit_event 任务列已删 assertDatabaseCount(t, db, `SELECT count(*) FROM information_schema.columns WHERE table_schema = current_schema() AND table_name = 'audit_event' AND column_name IN ('confirmation_id','confirmation_version','attempt_id','task_id','runtime_instance_id')`, 0) - // 统一登记表:1-38(除 36)、1017-1042、43 全部登记 - assertDatabaseCount(t, db, `SELECT count(*) FROM schema_migration`, 49) + // 统一登记表:1-38(除 36)、1017-1043、43 全部登记 + assertDatabaseCount(t, db, `SELECT count(*) FROM schema_migration`, 50) } diff --git a/internal/environment/migration_test.go b/internal/environment/migration_test.go index 9fd625c..f8cf13e 100644 --- a/internal/environment/migration_test.go +++ b/internal/environment/migration_test.go @@ -30,7 +30,7 @@ func TestUnifiedAccountMigration(t *testing.T) { t.Fatal(err) } defer db.Close() - assertDatabaseCount(t, db, `SELECT count(*) FROM schema_migration`, 49) + assertDatabaseCount(t, db, `SELECT count(*) FROM schema_migration`, 50) assertDatabaseCount(t, db, `SELECT count(*) FROM information_schema.tables WHERE table_schema = current_schema() AND table_name IN ('social_account', 'browser_env', 'network_exit', 'environment_binding')`, 3) assertDatabaseCount(t, db, `SELECT count(*) FROM information_schema.tables WHERE table_schema = current_schema() AND table_name = 'browser_image'`, 0) @@ -42,7 +42,7 @@ func TestUnifiedAccountMigration(t *testing.T) { store = openFullyMigratedHub(t, ctx, testURL) store.Close() - assertDatabaseCount(t, db, `SELECT count(*) FROM schema_migration`, 49) + assertDatabaseCount(t, db, `SELECT count(*) FROM schema_migration`, 50) }) t.Run("legacy migration 013 without account secrets is repaired forward", func(t *testing.T) { diff --git a/internal/environment/migrations/1043_gateway_health.sql b/internal/environment/migrations/1043_gateway_health.sql new file mode 100644 index 0000000..741dc0b --- /dev/null +++ b/internal/environment/migrations/1043_gateway_health.sql @@ -0,0 +1,5 @@ +-- 网关健康状态:后台定时探活只保留最近一次结果(不保留历史),列表接口直接读库展示。 +ALTER TABLE gateway ADD COLUMN health_status text NOT NULL DEFAULT 'unchecked' + CHECK (health_status IN ('unchecked', 'healthy', 'unhealthy')); +ALTER TABLE gateway ADD COLUMN last_check_reason text NOT NULL DEFAULT ''; +ALTER TABLE gateway ADD COLUMN last_checked_at timestamptz; diff --git a/internal/environment/store.go b/internal/environment/store.go index e8e2f53..9ac92c5 100644 --- a/internal/environment/store.go +++ b/internal/environment/store.go @@ -167,6 +167,9 @@ var migration1041 string //go:embed migrations/1042_work_cover_cache.sql var migration1042 string +//go:embed migrations/1043_gateway_health.sql +var migration1043 string + var ( ErrConflict = errors.New("resource conflicts with existing state") ErrInvalid = errors.New("invalid environment input") @@ -188,12 +191,16 @@ type Store struct { } // Gateway 是平台注册的 native browser gateway 节点;Token 由平台生成,明文存储供页面复制(开发阶段约定)。 +// 健康字段由控制面后台定时探活写入,只保留最近一次结果,不保留历史。 type Gateway struct { - Name string `json:"name"` - Endpoint string `json:"endpoint"` - Token string `json:"token"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` + Name string `json:"name"` + Endpoint string `json:"endpoint"` + Token string `json:"token"` + HealthStatus string `json:"health_status"` + LastCheckReason string `json:"last_check_reason"` + LastCheckedAt *time.Time `json:"last_checked_at"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` } // Env 是一个浏览器环境;浏览器安装和默认运行时由 gateway 宿主机配置。 @@ -302,7 +309,7 @@ func (s *Store) migrate(ctx context.Context) error { {1023, migration1023}, {1024, migration1024}, {1025, migration1025}, {1026, migration1026}, {1027, migration1027}, {1028, migration1028}, {1029, migration1029}, {1030, migration1030}, {1031, migration1031}, {1032, migration1032}, {1033, migration1033}, {1034, migration1034}, {1035, migration1035}, {1036, migration1036}, {1037, migration1037}, {1038, migration1038}, {1039, migration1039}, {1040, migration1040}, - {1041, migration1041}, {1042, migration1042}, + {1041, migration1041}, {1042, migration1042}, {1043, migration1043}, {43, migration043}, {44, migration044}} { var applied bool if err := tx.QueryRowContext(ctx, `SELECT EXISTS (SELECT 1 FROM schema_migration WHERE version = $1)`, migration.version).Scan(&applied); err != nil { @@ -324,6 +331,19 @@ func (s *Store) migrate(ctx context.Context) error { return nil } +func scanGateway(row rowScanner) (Gateway, error) { + var gateway Gateway + var checked sql.NullTime + if err := row.Scan(&gateway.Name, &gateway.Endpoint, &gateway.Token, &gateway.HealthStatus, + &gateway.LastCheckReason, &checked, &gateway.CreatedAt, &gateway.UpdatedAt); err != nil { + return Gateway{}, rowError(err) + } + if checked.Valid { + gateway.LastCheckedAt = &checked.Time + } + return gateway, nil +} + func (s *Store) CreateGateway(ctx context.Context, name, endpoint, token string) (Gateway, error) { name, endpoint, token = strings.TrimSpace(name), strings.TrimSpace(endpoint), strings.TrimSpace(token) if !gatewayNamePattern.MatchString(name) || !validHTTPURL(endpoint) { @@ -337,15 +357,13 @@ func (s *Store) CreateGateway(ctx context.Context, name, endpoint, token string) } else { token = newToken() } - gateway := Gateway{Name: name, Endpoint: endpoint, Token: token} - err := s.db.QueryRowContext(ctx, ` + result, err := scanGateway(s.db.QueryRowContext(ctx, ` INSERT INTO gateway (name, endpoint, token) VALUES ($1, $2, $3) - RETURNING created_at, updated_at`, name, endpoint, gateway.Token). - Scan(&gateway.CreatedAt, &gateway.UpdatedAt) + RETURNING name, endpoint, token, health_status, last_check_reason, last_checked_at, created_at, updated_at`, name, endpoint, token)) if err != nil { - return Gateway{}, publicDatabaseError(err) + return Gateway{}, err } - return gateway, nil + return result, nil } // UpdateGateway 修改网关名称、Endpoint 和令牌。名称变更由数据库外键 ON UPDATE CASCADE @@ -357,31 +375,26 @@ func (s *Store) UpdateGateway(ctx context.Context, currentName, name, endpoint, (token != "" && !tokenPattern.MatchString(token)) { return Gateway{}, ErrInvalid } - var gateway Gateway - err := s.db.QueryRowContext(ctx, ` + return scanGateway(s.db.QueryRowContext(ctx, ` UPDATE gateway SET name = $1, endpoint = $2, token = CASE WHEN $3 = '' THEN token ELSE $3 END, updated_at = now() WHERE name = $4 - RETURNING name, endpoint, token, created_at, updated_at`, name, endpoint, token, currentName). - Scan(&gateway.Name, &gateway.Endpoint, &gateway.Token, &gateway.CreatedAt, &gateway.UpdatedAt) - if err != nil { - return Gateway{}, rowError(err) - } - return gateway, nil + RETURNING name, endpoint, token, health_status, last_check_reason, last_checked_at, created_at, updated_at`, name, endpoint, token, currentName)) } func (s *Store) ListGateways(ctx context.Context) ([]Gateway, error) { rows, err := s.db.QueryContext(ctx, ` - SELECT name, endpoint, token, created_at, updated_at FROM gateway ORDER BY created_at, name`) + SELECT name, endpoint, token, health_status, last_check_reason, last_checked_at, created_at, updated_at + FROM gateway ORDER BY created_at, name`) if err != nil { return nil, errors.New("read gateways") } defer rows.Close() gateways := []Gateway{} for rows.Next() { - var gateway Gateway - if err := rows.Scan(&gateway.Name, &gateway.Endpoint, &gateway.Token, &gateway.CreatedAt, &gateway.UpdatedAt); err != nil { - return nil, errors.New("decode gateway") + gateway, err := scanGateway(rows) + if err != nil { + return nil, err } gateways = append(gateways, gateway) } @@ -389,17 +402,23 @@ func (s *Store) ListGateways(ctx context.Context) ([]Gateway, error) { } func (s *Store) GetGateway(ctx context.Context, name string) (Gateway, error) { - var gateway Gateway if !gatewayNamePattern.MatchString(name) { - return gateway, ErrInvalid + return Gateway{}, ErrInvalid } - err := s.db.QueryRowContext(ctx, ` - SELECT name, endpoint, token, created_at, updated_at FROM gateway WHERE name = $1`, name). - Scan(&gateway.Name, &gateway.Endpoint, &gateway.Token, &gateway.CreatedAt, &gateway.UpdatedAt) - if err != nil { - return Gateway{}, rowError(err) + return scanGateway(s.db.QueryRowContext(ctx, ` + SELECT name, endpoint, token, health_status, last_check_reason, last_checked_at, created_at, updated_at + FROM gateway WHERE name = $1`, name)) +} + +// RecordGatewayCheck 覆盖写入最近一次探活结果;不保留历史。status 只允许 healthy/unhealthy。 +func (s *Store) RecordGatewayCheck(ctx context.Context, name, status, reason string) (Gateway, error) { + if !gatewayNamePattern.MatchString(name) || (status != "healthy" && status != "unhealthy") { + return Gateway{}, ErrInvalid } - return gateway, nil + return scanGateway(s.db.QueryRowContext(ctx, ` + UPDATE gateway SET health_status = $2, last_check_reason = $3, last_checked_at = now(), updated_at = now() + WHERE name = $1 + RETURNING name, endpoint, token, health_status, last_check_reason, last_checked_at, created_at, updated_at`, name, status, reason)) } func (s *Store) DeleteGateway(ctx context.Context, name string) error { diff --git a/internal/environment/store_test.go b/internal/environment/store_test.go index 0dfd420..39d73ae 100644 --- a/internal/environment/store_test.go +++ b/internal/environment/store_test.go @@ -808,3 +808,62 @@ func TestNetworkExitLifecycleStoreOperations(t *testing.T) { t.Fatalf("list exits: exits=%+v err=%v", exits, err) } } + +func TestGatewayHealthCheckPersistence(t *testing.T) { + databaseURL := os.Getenv("CREATORHUB_POSTGRES_TEST_URL") + if databaseURL == "" { + t.Skip("set CREATORHUB_POSTGRES_TEST_URL to run PostgreSQL integration coverage") + } + ctx := context.Background() + store := openFullyMigratedHub(t, ctx, isolatedDatabaseURL(t, databaseURL)) + t.Cleanup(func() { _ = store.Close() }) + + gateway, err := store.CreateGateway(ctx, "gw-health", "http://127.0.0.1:8081", "") + if err != nil { + t.Fatal(err) + } + if gateway.HealthStatus != "unchecked" || gateway.LastCheckReason != "" || gateway.LastCheckedAt != nil { + t.Fatalf("new gateway must start unchecked: %#v", gateway) + } + + checked, err := store.RecordGatewayCheck(ctx, "gw-health", "healthy", "") + if err != nil { + t.Fatal(err) + } + if checked.HealthStatus != "healthy" || checked.LastCheckReason != "" || checked.LastCheckedAt == nil { + t.Fatalf("healthy check was not persisted: %#v", checked) + } + + failed, err := store.RecordGatewayCheck(ctx, "gw-health", "unhealthy", `Get "http://127.0.0.1:8081/healthz": connection refused`) + if err != nil { + t.Fatal(err) + } + if failed.HealthStatus != "unhealthy" || failed.LastCheckReason == "" || failed.LastCheckedAt == nil { + t.Fatalf("unhealthy check was not persisted: %#v", failed) + } + if !failed.LastCheckedAt.After(*checked.LastCheckedAt) && failed.LastCheckedAt.Equal(*checked.LastCheckedAt) { + t.Fatalf("last_checked_at must advance: %v", failed.LastCheckedAt) + } + + reread, err := store.GetGateway(ctx, "gw-health") + if err != nil || reread.HealthStatus != "unhealthy" || reread.LastCheckReason != failed.LastCheckReason { + t.Fatalf("health fields must round trip through get/list: %#v err=%v", reread, err) + } + listed, err := store.ListGateways(ctx) + if err != nil || len(listed) != 1 || listed[0].HealthStatus != "unhealthy" { + t.Fatalf("health fields must round trip through list: list=%#v err=%v", listed, err) + } + + if _, err := store.RecordGatewayCheck(ctx, "gw-health", "degraded", ""); !errors.Is(err, ErrInvalid) { + t.Fatalf("unknown status must be rejected, got %v", err) + } + if _, err := store.RecordGatewayCheck(ctx, "missing", "healthy", ""); !errors.Is(err, ErrNotFound) { + t.Fatalf("missing gateway must surface not found, got %v", err) + } + + // 改名/改地址不触碰健康字段(由探活负责覆盖)。 + renamed, err := store.UpdateGateway(ctx, "gw-health", "gw-health-2", "http://127.0.0.2:8081", "") + if err != nil || renamed.HealthStatus != "unhealthy" || renamed.LastCheckReason == "" { + t.Fatalf("update must preserve health fields: %#v err=%v", renamed, err) + } +} diff --git a/web/src/pages/gateways/index.tsx b/web/src/pages/gateways/index.tsx index 7411619..d11282d 100644 --- a/web/src/pages/gateways/index.tsx +++ b/web/src/pages/gateways/index.tsx @@ -1,15 +1,30 @@ // 网关管理:语义对齐 web.archived GatewaysPage.jsx(注册/编辑 Modal、令牌显隐复制、删除)。 import { useCallback, useEffect, useState } from 'react'; -import { Alert, App, Button, Card, Flex, Form, Input, Modal, Popconfirm, Space, Table, Typography } from 'antd'; +import { Alert, App, Button, Card, Flex, Form, Input, Modal, Popconfirm, Space, Table, Tag, Tooltip, Typography } from 'antd'; import { CopyOutlined, EyeInvisibleOutlined, EyeOutlined, PlusOutlined } from '@ant-design/icons'; import type { ColumnsType } from 'antd/es/table'; import { create, getList, remove, update } from '@/services/api'; -import { conflictMessage } from '@/utils/helpers'; +import { conflictMessage, dateTime } from '@/utils/helpers'; import { fixedLeft, fixedRight, tablePagination, tableScroll, useOverflowGrid } from '@/utils/table'; const namePattern = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/; const tokenPattern = /^[A-Za-z0-9][A-Za-z0-9._-]{15,127}$/; +// 健康状态列:只展示后台定时探活的最近一次结果,不保留历史。 +function HealthCell({ status, reason, checkedAt }: { status?: string; reason?: string; checkedAt?: string }) { + const label = status === 'healthy' ? '在线' : status === 'unhealthy' ? '离线' : '未检查'; + const color = status === 'healthy' ? 'success' : status === 'unhealthy' ? 'error' : 'default'; + const details = [ + checkedAt ? `最近检查:${dateTime(checkedAt)}` : null, + reason || null, + ].filter(Boolean).join(';'); + return ( + + {label} + + ); +} + function GatewayFormModal({ open, initial, @@ -146,6 +161,12 @@ export default function Page() { const columns: ColumnsType = [ { title: '名称', dataIndex: 'name', ...fixedLeft({}), render: (value: string) => {value} }, { title: 'Endpoint', dataIndex: 'endpoint', render: (value: string) => {value} }, + { + title: '健康状态', + dataIndex: 'health_status', + width: 110, + render: (_, gateway) => , + }, { title: '令牌', dataIndex: 'token', diff --git a/web/tests/gateway-health.test.cjs b/web/tests/gateway-health.test.cjs new file mode 100644 index 0000000..77b57d8 --- /dev/null +++ b/web/tests/gateway-health.test.cjs @@ -0,0 +1,40 @@ +const assert = require('node:assert/strict'); +const { readFileSync } = require('node:fs'); +const { test } = require('node:test'); +const ts = require('typescript'); + +// 网关健康状态列:后台定时探活只落最近一次结果,列表直接展示,不保留历史。 +const source = ts.createSourceFile( + 'gateways-page', + readFileSync(`${__dirname}/../src/pages/gateways/index.tsx`, 'utf8'), + ts.ScriptTarget.Latest, + true, + ts.ScriptKind.TSX, +); +const code = source.getFullText(); + +test('网关列表展示最近一次健康检查结果', () => { + assert.match(code, /健康状态/, '应有健康状态列'); + assert.match(code, /dataIndex: 'health_status'/, '应绑定 health_status 字段'); + assert.match(code, /gateway\.last_check_reason/, '应展示最近一次检查原因'); + assert.match(code, /gateway\.last_checked_at/, '应展示最近一次检查时间'); + assert.match(code, /online|在线/, '健康应展示为在线'); + assert.match(code, /offline|离线/, '不健康应展示为离线'); +}); + +test('健康状态未检查时不误报异常', () => { + // 未检查(unchecked)必须落在独立的静默分支,不允许与离线混用同一个颜色/文案。 + const render = code.match(/function HealthCell[\s\S]*?\n}/); + assert.ok(render, '应有独立的 HealthCell 组件'); + assert.match(render[0], /未检查/, '应存在未检查文案'); + const branches = render[0].match(/status === '[a-z]+'/g) ?? []; + assert.ok(branches.includes("status === 'healthy'"), '应判断 healthy'); + assert.ok(branches.includes("status === 'unhealthy'"), '应判断 unhealthy'); +}); + +test('检查原因与时间收进 Tooltip,不挤占表格密度', () => { + const render = code.match(/function HealthCell[\s\S]*?\n}/); + assert.ok(render, '应有独立的 HealthCell 组件'); + assert.match(render[0], /Tooltip/, '原因与时间应放 Tooltip'); + assert.doesNotMatch(render[0], /Typography\.Text type="secondary"/, '不应在单元格内平铺长文本'); +});