From d9f849abdb147ea18892e08a0853b2324705b3dc Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 31 Mar 2026 16:06:38 +0000 Subject: [PATCH 1/3] Initial plan From 6d20623e782a014c3c890a455575d97bd3302184 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 31 Mar 2026 16:15:24 +0000 Subject: [PATCH 2/3] Connect Elasticsearch via HTTP API with password authentication Agent-Logs-Url: https://github.com/keyscome/pulse/sessions/7e4cdb39-ad62-4593-b29b-747b9c470799 Co-authored-by: alanthssss <32271197+alanthssss@users.noreply.github.com> --- README.md | 26 +++++++++++++---- checker/elasticsearch.go | 39 ++++++++++++++++++++++++++ config.yml | 9 ++++-- config/config.go | 59 +++++++++++++++++++++++++++++++++++---- docker/config.yml | 5 +++- docker/docker-compose.yml | 13 +++++---- main.go | 22 +++++++++++++-- 7 files changed, 149 insertions(+), 24 deletions(-) create mode 100644 checker/elasticsearch.go diff --git a/README.md b/README.md index 8f830c8..4ea39c4 100644 --- a/README.md +++ b/README.md @@ -47,7 +47,9 @@ The script downloads the latest release, extracts it to `/usr/local/pulse`, and ## Configuration -Pulse reads a `config.yml` file from the **current working directory**. Each top-level key is a service name, and its value is a list of `host:port` addresses to check. +Pulse reads a `config.yml` file from the **current working directory**. Each top-level key is a service name. + +**Standard services** use a list of `host:port` addresses for TCP connectivity checks: ```yaml # config.yml – example @@ -60,8 +62,6 @@ redis: - 10.0.1.38:6380 kafka: - 10.0.1.30:9092 -elasticsearch: - - 10.0.1.24:9300 kibana: - 10.0.1.26:5601 minio: @@ -70,7 +70,21 @@ zookeeper: - 10.0.1.27:3000 ``` -Add or remove services as needed — any service name is accepted. +**Elasticsearch** uses a dedicated structured section that connects via the HTTP API (`/_cluster/health`) and supports Basic Auth password authentication: + +```yaml +elasticsearch: + addresses: + - 10.0.1.24:9200 + - 10.0.1.25:9200 + - 10.0.1.26:9200 + username: elastic + password: changeme +``` + +> **Note:** `username` and `password` are optional. Omit them (or leave them empty) for clusters with security disabled. + +Add or remove standard services as needed — any service name is accepted. ## Usage @@ -160,7 +174,7 @@ cp docker/config.yml config.yml |----|-----|---------------------| | Nacos | | nacos / nacos | | MinIO console | | minioadmin / minioadmin | -| Kibana | | — | +| Kibana | | elastic / changeme | | ZooKeeper Navigator | | — | ### Tear down @@ -197,7 +211,7 @@ Use the provided build scripts in the `build/` directory, or set the environment ``` pulse/ ├── build/ # Platform-specific build scripts -├── checker/ # TCP connection checker package +├── checker/ # TCP and Elasticsearch connection checker package ├── config/ # YAML configuration loader package ├── logger/ # Structured logger (success / failure / report) ├── scripts/ # Installation script diff --git a/checker/elasticsearch.go b/checker/elasticsearch.go new file mode 100644 index 0000000..57ec60d --- /dev/null +++ b/checker/elasticsearch.go @@ -0,0 +1,39 @@ +// checker/elasticsearch.go +package checker + +import ( + "fmt" + "io" + "net/http" + "time" +) + +// CheckElasticsearch 通过 Elasticsearch HTTP API(/_cluster/health)验证单个节点的连通性。 +// 若提供了 username 或 password,则使用 HTTP Basic Auth 进行认证。 +func CheckElasticsearch(address, username, password string, timeout time.Duration) error { + client := &http.Client{Timeout: timeout} + url := fmt.Sprintf("http://%s/_cluster/health", address) + + req, err := http.NewRequest(http.MethodGet, url, nil) + if err != nil { + return fmt.Errorf("创建请求失败: %w", err) + } + + if username != "" || password != "" { + req.SetBasicAuth(username, password) + } + + resp, err := client.Do(req) + if err != nil { + return fmt.Errorf("连接失败: %w", err) + } + defer resp.Body.Close() + if _, err = io.Copy(io.Discard, resp.Body); err != nil { + return fmt.Errorf("读取响应失败: %w", err) + } + + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("HTTP 状态 %d", resp.StatusCode) + } + return nil +} diff --git a/config.yml b/config.yml index 6d9dcd7..6691b77 100644 --- a/config.yml +++ b/config.yml @@ -14,9 +14,12 @@ kafka: - 10.0.1.31:9092 - 10.0.1.32:9092 elasticsearch: - - 10.0.1.24:9300 - - 10.0.1.25:9300 - - 10.0.1.26:9300 + addresses: + - 10.0.1.24:9200 + - 10.0.1.25:9200 + - 10.0.1.26:9200 + username: elastic + password: changeme kibana: - 10.0.1.26:5601 minio: diff --git a/config/config.go b/config/config.go index 0ad15a7..d3df57e 100644 --- a/config/config.go +++ b/config/config.go @@ -10,16 +10,63 @@ import ( // NetworkConfig 定义了各个服务的地址列表,键为服务名称,值为字符串数组 type NetworkConfig map[string][]string -// LoadConfig 从指定文件中读取 YAML 配置,并解析为 NetworkConfig 类型 -func LoadConfig(path string) (NetworkConfig, error) { +// ElasticsearchConfig 保存 Elasticsearch 集群的连接参数 +type ElasticsearchConfig struct { + Addresses []string `yaml:"addresses"` + Username string `yaml:"username"` + Password string `yaml:"password"` +} + +// AppConfig 是完整的应用配置,包含结构化的 Elasticsearch 配置和其他 TCP 服务的地址列表 +type AppConfig struct { + Elasticsearch *ElasticsearchConfig + Network NetworkConfig +} + +// rawConfig 用于从 YAML 文件中单独解析 elasticsearch 配置节 +type rawConfig struct { + Elasticsearch *ElasticsearchConfig `yaml:"elasticsearch"` +} + +// LoadConfig 从指定文件中读取 YAML 配置。 +// elasticsearch 键解析为 ElasticsearchConfig(支持用户名/密码认证); +// 其他所有键视为 TCP 地址列表。 +func LoadConfig(path string) (*AppConfig, error) { data, err := ioutil.ReadFile(path) if err != nil { return nil, err } - var cfg NetworkConfig - err = yaml.Unmarshal(data, &cfg) - if err != nil { + + // 解析结构化的 elasticsearch 节 + var raw rawConfig + if err = yaml.Unmarshal(data, &raw); err != nil { + return nil, err + } + + // 将所有键解析为通用 map,以便提取 TCP 服务地址列表 + var all map[string]interface{} + if err = yaml.Unmarshal(data, &all); err != nil { return nil, err } - return cfg, nil + + network := make(NetworkConfig) + for k, v := range all { + if k == "elasticsearch" { + continue // 由 ElasticsearchConfig 单独处理 + } + if list, ok := v.([]interface{}); ok { + addrs := make([]string, 0, len(list)) + for _, a := range list { + if s, ok := a.(string); ok { + addrs = append(addrs, s) + } + } + network[k] = addrs + } + } + + return &AppConfig{ + Elasticsearch: raw.Elasticsearch, + Network: network, + }, nil } diff --git a/docker/config.yml b/docker/config.yml index 4df4646..edcfe70 100644 --- a/docker/config.yml +++ b/docker/config.yml @@ -21,7 +21,10 @@ kafka: - localhost:9092 elasticsearch: - - localhost:9300 + addresses: + - localhost:9200 + username: elastic + password: changeme kibana: - localhost:5601 diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml index e82062e..6e7a392 100644 --- a/docker/docker-compose.yml +++ b/docker/docker-compose.yml @@ -13,7 +13,7 @@ # Default credentials # MinIO console http://localhost:9001 minioadmin / minioadmin # Nacos console http://localhost:8848/nacos nacos / nacos -# Kibana http://localhost:5601 +# Kibana http://localhost:5601 elastic / changeme # ZK Navigator http://localhost:9090 version: "3.9" @@ -111,12 +111,13 @@ services: environment: discovery.type: single-node ES_JAVA_OPTS: "-Xms256m -Xmx256m" - xpack.security.enabled: "false" + xpack.security.enabled: "true" + ELASTIC_PASSWORD: "changeme" ports: - - "9200:9200" # HTTP API - - "9300:9300" # transport (inter-node / monitored by Pulse) + - "9200:9200" # HTTP API (monitored by Pulse) + - "9300:9300" # transport (inter-node) healthcheck: - test: ["CMD", "curl", "-sf", "http://localhost:9200/_cluster/health"] + test: ["CMD", "curl", "-sf", "-u", "elastic:changeme", "http://localhost:9200/_cluster/health"] interval: 30s timeout: 10s retries: 5 @@ -128,6 +129,8 @@ services: container_name: pulse-kibana environment: ELASTICSEARCH_HOSTS: "http://elasticsearch:9200" + ELASTICSEARCH_USERNAME: "elastic" + ELASTICSEARCH_PASSWORD: "changeme" ports: - "5601:5601" depends_on: diff --git a/main.go b/main.go index 1668fbb..ce8e85c 100644 --- a/main.go +++ b/main.go @@ -46,8 +46,24 @@ func main() { // 设置检测超时时间 timeout := 3 * time.Second - // 遍历配置中的每个服务类型及其地址列表 - for service, addresses := range cfg { + // 检测 Elasticsearch 集群(HTTP API + 可选 Basic Auth) + if cfg.Elasticsearch != nil { + esResult := ServiceResult{Success: []string{}, Failure: []string{}} + for _, addr := range cfg.Elasticsearch.Addresses { + err := checker.CheckElasticsearch(addr, cfg.Elasticsearch.Username, cfg.Elasticsearch.Password, timeout) + if err != nil { + failureLogger.Printf("[elasticsearch] 连接 %s 失败: %v", addr, err) + esResult.Failure = append(esResult.Failure, addr) + } else { + successLogger.Printf("[elasticsearch] 连接 %s 成功", addr) + esResult.Success = append(esResult.Success, addr) + } + } + results["elasticsearch"] = esResult + } + + // 遍历配置中的每个 TCP 服务类型及其地址列表 + for service, addresses := range cfg.Network { // 初始化结果记录 results[service] = ServiceResult{ Success: []string{}, @@ -60,7 +76,7 @@ func main() { } for _, addr := range addresses { - // 检测连接 + // 检测 TCP 连接 err := checker.CheckConnection(addr, timeout) if err != nil { failureLogger.Printf("[%s] 连接 %s 失败: %v", service, addr, err) From 01b8acf336359911deab184d85746678e8ad2c21 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 31 Mar 2026 17:40:51 +0000 Subject: [PATCH 3/3] Fix indentation: apply gofmt to all Go files Agent-Logs-Url: https://github.com/keyscome/pulse/sessions/108c330e-5f23-451a-9057-eb6b761c4c95 Co-authored-by: alanthssss <32271197+alanthssss@users.noreply.github.com> --- checker/zookeeper.go | 1 - config/config.go | 56 ++++----- main.go | 262 +++++++++++++++++++++---------------------- 3 files changed, 159 insertions(+), 160 deletions(-) diff --git a/checker/zookeeper.go b/checker/zookeeper.go index 1307183..aa688be 100644 --- a/checker/zookeeper.go +++ b/checker/zookeeper.go @@ -8,7 +8,6 @@ import ( "time" ) - // CheckZookeeperConnection 验证 Zookeeper 节点的连通性,使用 Zookeeper 四字命令 "ruok"。 // connectString 可以是单个 "host:port",也可以是标准 Zookeeper 连接字符串, // 例如 "host1:2181,host2:2181,host3:2181" 或 "host1:2181,host2:2181/chroot"。 diff --git a/config/config.go b/config/config.go index 06afa0e..e5e83e5 100644 --- a/config/config.go +++ b/config/config.go @@ -2,9 +2,9 @@ package config import ( -"os" + "os" -"gopkg.in/yaml.v2" + "gopkg.in/yaml.v2" ) // NetworkConfig 定义了各个服务的地址列表,键为服务名称,值为字符串数组 @@ -12,51 +12,51 @@ type NetworkConfig map[string][]string // ElasticsearchConfig 保存 Elasticsearch 集群的连接参数 type ElasticsearchConfig struct { -Addresses []string `yaml:"addresses"` -Username string `yaml:"username"` -Password string `yaml:"password"` + Addresses []string `yaml:"addresses"` + Username string `yaml:"username"` + Password string `yaml:"password"` } // KibanaConfig 定义了 Kibana 服务的连接配置,支持用户名和密码认证 type KibanaConfig struct { -Addresses []string `yaml:"addresses"` -Username string `yaml:"username"` -Password string `yaml:"password"` + Addresses []string `yaml:"addresses"` + Username string `yaml:"username"` + Password string `yaml:"password"` } // MinioConfig 保存 MinIO 的认证信息及地址列表 type MinioConfig struct { -Username string `yaml:"username"` -Password string `yaml:"password"` -UseSSL bool `yaml:"use_ssl"` -Addresses []string `yaml:"addresses"` + Username string `yaml:"username"` + Password string `yaml:"password"` + UseSSL bool `yaml:"use_ssl"` + Addresses []string `yaml:"addresses"` } // RedisConfig holds Redis-specific configuration including an optional password // and the list of Redis addresses to check. type RedisConfig struct { -Password string `yaml:"password"` -Addresses []string `yaml:"addresses"` + Password string `yaml:"password"` + Addresses []string `yaml:"addresses"` } // AppConfig 顶层配置结构,包含通用 TCP 服务、MinIO、Kibana、Redis 和 Elasticsearch 专项配置 type AppConfig struct { -Services NetworkConfig `yaml:"services"` -Minio *MinioConfig `yaml:"minio"` -Kibana KibanaConfig `yaml:"kibana"` -Redis RedisConfig `yaml:"redis"` -Elasticsearch *ElasticsearchConfig `yaml:"elasticsearch"` + Services NetworkConfig `yaml:"services"` + Minio *MinioConfig `yaml:"minio"` + Kibana KibanaConfig `yaml:"kibana"` + Redis RedisConfig `yaml:"redis"` + Elasticsearch *ElasticsearchConfig `yaml:"elasticsearch"` } // LoadConfig 从指定文件中读取 YAML 配置,并解析为 AppConfig 类型 func LoadConfig(path string) (*AppConfig, error) { -data, err := os.ReadFile(path) -if err != nil { -return nil, err -} -var cfg AppConfig -if err = yaml.Unmarshal(data, &cfg); err != nil { -return nil, err -} -return &cfg, nil + data, err := os.ReadFile(path) + if err != nil { + return nil, err + } + var cfg AppConfig + if err = yaml.Unmarshal(data, &cfg); err != nil { + return nil, err + } + return &cfg, nil } diff --git a/main.go b/main.go index 9d77839..33aa3ff 100644 --- a/main.go +++ b/main.go @@ -2,16 +2,16 @@ package main import ( -"bytes" -"fmt" -"os" -"text/template" -"time" - -"github.com/keyscome/pulse/checker" -"github.com/keyscome/pulse/config" -"github.com/keyscome/pulse/kafka" -"github.com/keyscome/pulse/logger" + "bytes" + "fmt" + "os" + "text/template" + "time" + + "github.com/keyscome/pulse/checker" + "github.com/keyscome/pulse/config" + "github.com/keyscome/pulse/kafka" + "github.com/keyscome/pulse/logger" ) // ServiceKafka is the config key that selects the Kafka protocol-level checker. @@ -19,137 +19,137 @@ const ServiceKafka = "kafka" // ReportData 用于模板渲染,记录每个服务检测的成功和失败结果 type ReportData struct { -Timestamp string -Results map[string]ServiceResult + Timestamp string + Results map[string]ServiceResult } // ServiceResult 保存某个服务下成功和失败的地址列表 type ServiceResult struct { -Success []string -Failure []string + Success []string + Failure []string } // recordResult 根据检测结果将地址记录到对应服务的成功或失败列表中,并写入日志 func recordResult(results map[string]ServiceResult, service, addr string, err error, successLogger, failureLogger interface { -Printf(string, ...interface{}) + Printf(string, ...interface{}) }) { -tmp := results[service] -if err != nil { -failureLogger.Printf("[%s] 连接 %s 失败: %v", service, addr, err) -tmp.Failure = append(tmp.Failure, addr) -} else { -successLogger.Printf("[%s] 连接 %s 成功", service, addr) -tmp.Success = append(tmp.Success, addr) -} -results[service] = tmp + tmp := results[service] + if err != nil { + failureLogger.Printf("[%s] 连接 %s 失败: %v", service, addr, err) + tmp.Failure = append(tmp.Failure, addr) + } else { + successLogger.Printf("[%s] 连接 %s 成功", service, addr) + tmp.Success = append(tmp.Success, addr) + } + results[service] = tmp } func main() { -// 初始化日志记录器 -successLogger, failureLogger, reportLogger, cleanup, err := logger.NewLoggers() -if err != nil { -fmt.Printf("初始化日志失败: %v\n", err) -os.Exit(1) -} -defer cleanup() - -// 加载配置文件 config.yml -cfg, err := config.LoadConfig("config.yml") -if err != nil { -failureLogger.Fatalf("加载配置文件失败: %v", err) -} - -// 准备存储检测结果,按服务分类 -results := make(map[string]ServiceResult) - -// 设置检测超时时间 -timeout := 3 * time.Second - -// ── Redis(支持密码认证)────────────────────────────────────────────── -if len(cfg.Redis.Addresses) > 0 && !(len(cfg.Redis.Addresses) == 1 && cfg.Redis.Addresses[0] == "") { -results["redis"] = ServiceResult{Success: []string{}, Failure: []string{}} -for _, addr := range cfg.Redis.Addresses { -err := checker.CheckRedisConnection(addr, cfg.Redis.Password, timeout) -recordResult(results, "redis", addr, err, successLogger, failureLogger) -} -} - -// ── 通用 TCP 服务检测(zookeeper/kafka 使用专用检测器)─────────────── -for service, addresses := range cfg.Services { -results[service] = ServiceResult{Success: []string{}, Failure: []string{}} - -// 跳过空列表(或只有空字符串的列表) -if len(addresses) == 0 || (len(addresses) == 1 && addresses[0] == "") { -continue -} - -for _, addr := range addresses { -// 检测连接:kafka/zookeeper 使用各自专用检测器,其余使用通用 TCP 检测 -var connErr error -switch service { -case ServiceKafka: -connErr = kafka.CheckConnection(addr, timeout) -case "zookeeper": -connErr = checker.CheckZookeeperConnection(addr, timeout) -default: -connErr = checker.CheckConnection(addr, timeout) -} -recordResult(results, service, addr, connErr, successLogger, failureLogger) -} -} - -// ── Elasticsearch 集群(HTTP API + 可选 Basic Auth)────────────────── -if cfg.Elasticsearch != nil { -results["elasticsearch"] = ServiceResult{Success: []string{}, Failure: []string{}} -for _, addr := range cfg.Elasticsearch.Addresses { -err := checker.CheckElasticsearch(addr, cfg.Elasticsearch.Username, cfg.Elasticsearch.Password, timeout) -recordResult(results, "elasticsearch", addr, err, successLogger, failureLogger) -} -} - -// ── MinIO 认证连接检测 ──────────────────────────────────────────────── -if cfg.Minio != nil { -results["minio"] = ServiceResult{Success: []string{}, Failure: []string{}} -for _, addr := range cfg.Minio.Addresses { -if addr == "" { -continue -} -err := checker.CheckMinioConnection(addr, cfg.Minio.Username, cfg.Minio.Password, cfg.Minio.UseSSL, timeout) -recordResult(results, "minio", addr, err, successLogger, failureLogger) -} -} - -// ── Kibana(HTTP 基础认证)──────────────────────────────────────────── -if len(cfg.Kibana.Addresses) > 0 { -results["kibana"] = ServiceResult{Success: []string{}, Failure: []string{}} -for _, addr := range cfg.Kibana.Addresses { -err := checker.CheckKibanaConnection(addr, cfg.Kibana.Username, cfg.Kibana.Password, timeout) -recordResult(results, "kibana", addr, err, successLogger, failureLogger) -} -} - -// 使用 report.tpl 模板生成检测报告 -reportTpl, err := template.ParseFiles("report.tpl") -if err != nil { -failureLogger.Fatalf("解析模板文件失败: %v", err) -} - -reportData := ReportData{ -Timestamp: time.Now().Format("2006-01-02 15:04:05"), -Results: results, -} - -// 将报告先渲染到内存缓冲区 -var reportBuffer bytes.Buffer -err = reportTpl.Execute(&reportBuffer, reportData) -if err != nil { -failureLogger.Fatalf("生成报告失败: %v", err) -} - -// 将检测报告输出到 stdout(同时 reportLogger 也配置了 stdout) -fmt.Println("\n===== 检测报告 =====") -fmt.Println(reportBuffer.String()) - -// 同时记录报告到日志文件 -reportLogger.Println(reportBuffer.String()) + // 初始化日志记录器 + successLogger, failureLogger, reportLogger, cleanup, err := logger.NewLoggers() + if err != nil { + fmt.Printf("初始化日志失败: %v\n", err) + os.Exit(1) + } + defer cleanup() + + // 加载配置文件 config.yml + cfg, err := config.LoadConfig("config.yml") + if err != nil { + failureLogger.Fatalf("加载配置文件失败: %v", err) + } + + // 准备存储检测结果,按服务分类 + results := make(map[string]ServiceResult) + + // 设置检测超时时间 + timeout := 3 * time.Second + + // ── Redis(支持密码认证)────────────────────────────────────────────── + if len(cfg.Redis.Addresses) > 0 && !(len(cfg.Redis.Addresses) == 1 && cfg.Redis.Addresses[0] == "") { + results["redis"] = ServiceResult{Success: []string{}, Failure: []string{}} + for _, addr := range cfg.Redis.Addresses { + err := checker.CheckRedisConnection(addr, cfg.Redis.Password, timeout) + recordResult(results, "redis", addr, err, successLogger, failureLogger) + } + } + + // ── 通用 TCP 服务检测(zookeeper/kafka 使用专用检测器)─────────────── + for service, addresses := range cfg.Services { + results[service] = ServiceResult{Success: []string{}, Failure: []string{}} + + // 跳过空列表(或只有空字符串的列表) + if len(addresses) == 0 || (len(addresses) == 1 && addresses[0] == "") { + continue + } + + for _, addr := range addresses { + // 检测连接:kafka/zookeeper 使用各自专用检测器,其余使用通用 TCP 检测 + var connErr error + switch service { + case ServiceKafka: + connErr = kafka.CheckConnection(addr, timeout) + case "zookeeper": + connErr = checker.CheckZookeeperConnection(addr, timeout) + default: + connErr = checker.CheckConnection(addr, timeout) + } + recordResult(results, service, addr, connErr, successLogger, failureLogger) + } + } + + // ── Elasticsearch 集群(HTTP API + 可选 Basic Auth)────────────────── + if cfg.Elasticsearch != nil { + results["elasticsearch"] = ServiceResult{Success: []string{}, Failure: []string{}} + for _, addr := range cfg.Elasticsearch.Addresses { + err := checker.CheckElasticsearch(addr, cfg.Elasticsearch.Username, cfg.Elasticsearch.Password, timeout) + recordResult(results, "elasticsearch", addr, err, successLogger, failureLogger) + } + } + + // ── MinIO 认证连接检测 ──────────────────────────────────────────────── + if cfg.Minio != nil { + results["minio"] = ServiceResult{Success: []string{}, Failure: []string{}} + for _, addr := range cfg.Minio.Addresses { + if addr == "" { + continue + } + err := checker.CheckMinioConnection(addr, cfg.Minio.Username, cfg.Minio.Password, cfg.Minio.UseSSL, timeout) + recordResult(results, "minio", addr, err, successLogger, failureLogger) + } + } + + // ── Kibana(HTTP 基础认证)──────────────────────────────────────────── + if len(cfg.Kibana.Addresses) > 0 { + results["kibana"] = ServiceResult{Success: []string{}, Failure: []string{}} + for _, addr := range cfg.Kibana.Addresses { + err := checker.CheckKibanaConnection(addr, cfg.Kibana.Username, cfg.Kibana.Password, timeout) + recordResult(results, "kibana", addr, err, successLogger, failureLogger) + } + } + + // 使用 report.tpl 模板生成检测报告 + reportTpl, err := template.ParseFiles("report.tpl") + if err != nil { + failureLogger.Fatalf("解析模板文件失败: %v", err) + } + + reportData := ReportData{ + Timestamp: time.Now().Format("2006-01-02 15:04:05"), + Results: results, + } + + // 将报告先渲染到内存缓冲区 + var reportBuffer bytes.Buffer + err = reportTpl.Execute(&reportBuffer, reportData) + if err != nil { + failureLogger.Fatalf("生成报告失败: %v", err) + } + + // 将检测报告输出到 stdout(同时 reportLogger 也配置了 stdout) + fmt.Println("\n===== 检测报告 =====") + fmt.Println(reportBuffer.String()) + + // 同时记录报告到日志文件 + reportLogger.Println(reportBuffer.String()) }