feat(collectors): 主机采集器容器优先托管 + 采集器日志查看 + 推送地址纠正
现象 1. 主机采集器只有 systemd 一条路,跨发行版差异大(这轮已踩过 param 被删、日志路径、 时间戳格式三处);进程存活/开机自启也没有统一保证。 2. 「采集器日志」在主机模式下无法从平台查看,排「起没起来/在报什么错」必须 SSH。 3. 实测发现:Loki 部署把 listenIP 填成 0.0.0.0 时,部署记录 endpoint 是 http://0.0.0.0:3100,采集器照抄去推 -> connect: connection refused, 而容器/systemd 都是 running、平台 status=active —— 日志静默丢失。 根因 1. 托管方式写死在代码里(promtail 二进制 + systemd 单元),没有能力探测。 2. 没有对外暴露采集器自身日志的接口。 3. 渲染 promtail 配置时直接用 deployment.Endpoint,没有校验它是不是「可连接的推送地址」; 另外容器用 bridge 网络,127.0.0.1 在容器里指向容器自身,同机 Loki 也会推失败。 修法 - services/collector_host_runtime.go(新增) * detectHostCapabilities:一次 SSH 往返探测 架构/init/systemd/docker可用/开机自启/SELinux/journald * chooseHostRunMode:docker 优先 -> systemd 兜底 -> 两者都没有则明确报错(不裸跑进程) * installHostDocker:grafana/promtail:3.4.0 容器,--restart=always --memory=256m --cpus=1 --log-opt max-size=10m,max-file=3,挂 /etc/promtail(:ro) /var/lib/promtail /var/log(:ro) (+journald 相关),SELinux Enforcing 时 --security-opt label=disable; 先 docker run --rm -check-syntax 校验配置,再替换正式配置 * waitContainerRunning:看 Status + RestartCount,起来就崩立即报错并带回 docker logs * hostPushEndpoint:0.0.0.0/:: 换真实主机 IP;容器模式下 127.0.0.1/localhost 也换; 换不了明确报错 * CollectorLogs:主机 docker logs / journalctl,K8s DaemonSet Pod 日志、Sidecar fluent-bit 容器日志 - collector_deployer.go:installHost 探测->选模式->渲染配置->分发;systemd 路径从 docker 迁回时 清理同名容器;卸载时容器与 systemd 两种方式都清(RunMode 对老记录是空的) - 拉不到镜像(内网无外网)时降级 systemd 二进制托管,其它错误原样抛出 - models/collector.go:新增 runMode 并纳入 Sanitized;services.Update 白名单同步 - 接口 GET /api/v1/collectors/:id/logs?tail=N;前端卡片加「运行方式」+「日志」弹窗 - build.sh:detect_go 的 INFO 走 stderr(原来被打进 GO_CMD,构建必失败) 验证(172.19.16.8 / pre-cex-ops-jenkins,collector id=2) - 安装后 runMode=docker,容器 Up,RestartPolicy=always,systemd promtail 自动停用 - 推送地址自动纠正为 http://172.19.16.8:3100,Loki 近 10 分钟查到 4919 条 job=system/job=app、host=pre-cex-ops-jenkins - docker 垫片模拟「docker 不可用」-> 自动回退 runMode=systemd、unit active+enabled、 日志接口 source=systemd;移除垫片重装 -> 切回 docker - 卸载:容器 0 个、unit 文件不存在、/etc/promtail 不存在、runMode 清空;重装恢复 running/0 重启 - K8s 采集器日志接口 source=kubernetes(顺带暴露 429 限流,见后续日志量治理) - go vet 干净;go test ./... 全绿(新增 chooseHostRunMode / hostPushEndpoint 等 6 组用例); tsc -b 通过 - 镜像 lghost/lokistack:latest = :v0.1 = sha256:906a6d5369d7,Pod imageID 一致
This commit is contained in:
1 parent
c983d0e3c1
commit
7e3356e32b
16 files changed
+1095
-69
No files matched your search
@@ -1,6 +1,8 @@
|
||||
package controllers
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
|
||||
"lokistack/dto"
|
||||
"lokistack/models"
|
||||
"lokistack/services"
|
||||
@@ -131,3 +133,26 @@ func (ctrl *CollectorController) Uninstall(c *gin.Context) {
|
||||
go ctrl.service.Uninstall(uint(id))
|
||||
utils.Success(c, gin.H{"message": "已开始卸载采集器"})
|
||||
}
|
||||
|
||||
// Logs 返回采集器最近的运行日志(主机:docker logs / journalctl;K8s:Pod 日志)。
|
||||
// 主机模式下采集器日志原来只能登机器看 docker logs / journalctl,容器化后这是
|
||||
// 顺带拿到的能力,排「采集器起没起来、在报什么错」不用再 SSH。
|
||||
func (ctrl *CollectorController) Logs(c *gin.Context) {
|
||||
id, err := utils.IntParam(c, "id")
|
||||
if err != nil {
|
||||
utils.BadRequest(c, "ID 无效")
|
||||
return
|
||||
}
|
||||
tail := 200
|
||||
if v := c.Query("tail"); v != "" {
|
||||
if n, convErr := strconv.Atoi(v); convErr == nil && n > 0 {
|
||||
tail = n
|
||||
}
|
||||
}
|
||||
out, err := ctrl.service.CollectorLogs(uint(id), tail)
|
||||
if err != nil {
|
||||
utils.Error(c, 400, err.Error())
|
||||
return
|
||||
}
|
||||
utils.Success(c, out)
|
||||
}
|
||||
@@ -37,14 +37,18 @@ type Collector struct {
|
||||
// 存下来才能让「配置预览」和线上真实配置一致。
|
||||
// SystemLogPaths 安装时探测到的系统日志绝对路径(Ubuntu/Debian 与 RHEL 系不同),
|
||||
// 存下来让「预览配置」和实际下发保持一致。
|
||||
SystemLogPaths []string `json:"systemLogPaths,omitempty"`
|
||||
ResolvedHostname string `json:"resolvedHostname,omitempty"`
|
||||
SyslogFormat string `json:"syslogFormat,omitempty"` // iso8601 | syslog
|
||||
Status string `json:"status"` // pending | active | error
|
||||
StatusMessage string `json:"statusMessage"`
|
||||
Config string `json:"config"` // 生成的配置快照
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
SystemLogPaths []string `json:"systemLogPaths,omitempty"`
|
||||
ResolvedHostname string `json:"resolvedHostname,omitempty"`
|
||||
SyslogFormat string `json:"syslogFormat,omitempty"` // iso8601 | syslog
|
||||
// RunMode 主机采集器的托管方式:docker | systemd。
|
||||
// 安装时由目标机能力探测决定(docker 优先、systemd 兜底),必须落库:
|
||||
// 卸载/升级要知道该删容器还是删 systemd 单元,否则会留孤儿进程继续采日志。
|
||||
RunMode string `json:"runMode,omitempty"`
|
||||
Status string `json:"status"` // pending | active | error
|
||||
StatusMessage string `json:"statusMessage"`
|
||||
Config string `json:"config"` // 生成的配置快照
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
}
|
||||
|
||||
func (c *Collector) Sanitized() map[string]interface{} {
|
||||
@@ -89,6 +93,7 @@ func (c *Collector) Sanitized() map[string]interface{} {
|
||||
"systemLogPaths": c.SystemLogPaths,
|
||||
"resolvedHostname": c.ResolvedHostname,
|
||||
"syslogFormat": c.SyslogFormat,
|
||||
"runMode": c.RunMode,
|
||||
"status": c.Status,
|
||||
"statusMessage": c.StatusMessage,
|
||||
"createdAt": c.CreatedAt,
|
||||
|
||||
@@ -197,6 +197,7 @@ func SetupRouter() *gin.Engine {
|
||||
authorized.POST("/collectors/:id/install", collectorCtrl.Install)
|
||||
authorized.POST("/collectors/:id/uninstall", collectorCtrl.Uninstall)
|
||||
authorized.POST("/collectors/:id/preview", collectorCtrl.Preview)
|
||||
authorized.GET("/collectors/:id/logs", collectorCtrl.Logs)
|
||||
|
||||
// Static files - use executable directory as base
|
||||
distPath := findDistPath()
|
||||
|
||||
@@ -107,8 +107,17 @@ func (s *CollectorService) renderConfig(c *models.Collector) (string, error) {
|
||||
if hostname == "" && c.Host != nil {
|
||||
hostname = c.Host.HostIP
|
||||
}
|
||||
// 预览要与线上一致:这里也走一遍推送地址纠正(0.0.0.0 / 容器内回环)
|
||||
pushEndpoint := endpoint
|
||||
hostIP := ""
|
||||
if c.Host != nil {
|
||||
hostIP = c.Host.HostIP
|
||||
}
|
||||
if v, perr := hostPushEndpoint(endpoint, hostIP, c.RunMode == HostRunModeDocker); perr == nil {
|
||||
pushEndpoint = v
|
||||
}
|
||||
return PromtailConfigForHost(HostPromtailOptions{
|
||||
Endpoint: endpoint,
|
||||
Endpoint: pushEndpoint,
|
||||
Cluster: "host",
|
||||
LabelsJSON: c.Labels,
|
||||
Hostname: hostname,
|
||||
@@ -184,6 +193,19 @@ func (s *CollectorService) PreviewConfig(id uint, updates map[string]interface{}
|
||||
if c.ResolvedHostname == "" {
|
||||
warnings = append(warnings, "尚未成功安装过:host 标签将使用平台登记的主机名,实际安装时会替换为目标机真实 hostname")
|
||||
}
|
||||
// 推送地址填写了通配/回环地址时提前提示:这类地址采集器推不上去,
|
||||
// 表现是「容器/systemd 都 running,但 Loki 里一条日志都没有」。
|
||||
rawEndpoint := ""
|
||||
if c.LokiDeployment != nil {
|
||||
rawEndpoint = c.LokiDeployment.Endpoint
|
||||
}
|
||||
hostIP := ""
|
||||
if c.Host != nil {
|
||||
hostIP = c.Host.HostIP
|
||||
}
|
||||
if fixed, perr := hostPushEndpoint(rawEndpoint, hostIP, c.RunMode == HostRunModeDocker); perr == nil && fixed != rawEndpoint {
|
||||
warnings = append(warnings, fmt.Sprintf("Loki 推送地址 %s 不可用,部署时会自动改为 %s(建议修正 Loki 部署的监听地址)", rawEndpoint, fixed))
|
||||
}
|
||||
}
|
||||
return map[string]interface{}{"config": cfg, "warnings": warnings, "hostname": c.ResolvedHostname, "syslogFormat": c.SyslogFormat}, nil
|
||||
}
|
||||
@@ -412,6 +434,10 @@ func (s *CollectorService) Update(id uint, updates map[string]interface{}) error
|
||||
if v, ok := updates["systemLogPaths"].([]string); ok && len(v) > 0 {
|
||||
db.Collectors[i].SystemLogPaths = v
|
||||
}
|
||||
// runMode:安装时探测出的托管方式(docker | systemd),卸载后清空。
|
||||
if v, ok := updates["runMode"].(string); ok {
|
||||
db.Collectors[i].RunMode = v
|
||||
}
|
||||
if v, ok := updates["config"].(string); ok && v != "" {
|
||||
db.Collectors[i].Config = v
|
||||
}
|
||||
@@ -478,6 +504,8 @@ func (s *CollectorService) Install(id uint) {
|
||||
if len(collector.SystemLogPaths) > 0 {
|
||||
updates["systemLogPaths"] = collector.SystemLogPaths
|
||||
}
|
||||
// 托管方式(docker | systemd)由安装流程探测后写回,前端据此展示运行方式
|
||||
updates["runMode"] = collector.RunMode
|
||||
s.Update(id, updates)
|
||||
}
|
||||
|
||||
@@ -493,5 +521,5 @@ func (s *CollectorService) Uninstall(id uint) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
|
||||
defer cancel()
|
||||
NewCollectorDeployer(collector, collector.LokiDeployment, nil).Uninstall(ctx)
|
||||
s.Update(id, map[string]interface{}{"status": "pending", "statusMessage": "采集器已卸载"})
|
||||
s.Update(id, map[string]interface{}{"status": "pending", "statusMessage": "采集器已卸载", "runMode": ""})
|
||||
}
|
||||
@@ -3,6 +3,7 @@ package services
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
"strings"
|
||||
@@ -69,8 +70,19 @@ func (d *CollectorDeployer) Uninstall(ctx context.Context) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// ===== host-promtail:SSH 安装 Promtail 二进制 + systemd =====
|
||||
// ===== host-promtail:托管方式自动探测(docker 优先,systemd 兜底) =====
|
||||
|
||||
// installHost 主机采集器安装入口。
|
||||
//
|
||||
// 「进程活着」这件事不该由平台自己扛:平台的职责是「期望状态 + 配置下发 + 健康校验」,
|
||||
// 存活/开机自启交给成熟的监督者。所以按目标机能力自动选托管方式:
|
||||
//
|
||||
// ① 有可用 Docker(docker info 成功)→ 容器(--restart=always,跨发行版抹平差异)
|
||||
// ② 无 Docker 但有 systemd → 二进制 + systemd 单元
|
||||
// ③ 两者都没有 → 明确报错,绝不裸跑进程
|
||||
//
|
||||
// 第 ③ 条是硬约束:`nohup promtail &` 起的进程主机重启即消失,平台不主动巡检就会
|
||||
// 静默断采;要补齐就得自己实现存活检测/PID 管理/开机自启,等于重造一个更差的 supervisor。
|
||||
func (d *CollectorDeployer) installHost(ctx context.Context, endpoint string) error {
|
||||
host := d.collector.Host
|
||||
if host == nil {
|
||||
@@ -82,57 +94,74 @@ func (d *CollectorDeployer) installHost(ctx context.Context, endpoint string) er
|
||||
}
|
||||
defer client.Close()
|
||||
|
||||
// 检测架构
|
||||
archOut, _, _ := client.Execute(ctx, "uname -m")
|
||||
arch := strings.TrimSpace(archOut)
|
||||
switch arch {
|
||||
case "x86_64":
|
||||
arch = "amd64"
|
||||
case "aarch64", "arm64":
|
||||
arch = "arm64"
|
||||
default:
|
||||
return fmt.Errorf("不支持的主机架构: %s", arch)
|
||||
caps := detectHostCapabilities(ctx, client)
|
||||
d.log("info", fmt.Sprintf("目标机探测:架构 %s / init %s / docker %s / SELinux %s",
|
||||
caps.Arch, caps.InitName, caps.DockerSummary(), caps.SELinux), "")
|
||||
|
||||
if err := ensureHostPromtailDirs(ctx, client); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 确定 Promtail 版本(优先使用关联的 Loki 版本)
|
||||
version := "3.0.0"
|
||||
if d.loki != nil && d.loki.Version != "" {
|
||||
version = d.loki.Version
|
||||
// 先定托管方式,再渲染配置:容器模式下回环地址必须换掉(bridge 里 127.0.0.1 指容器自己)
|
||||
mode, err := chooseHostRunMode(caps)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 创建必要目录
|
||||
d.log("info", "准备目录 /opt/promtail /etc/promtail /var/lib/promtail", "")
|
||||
if _, _, err := client.Execute(ctx, "mkdir -p /opt/promtail /etc/promtail /var/lib/promtail"); err != nil {
|
||||
return fmt.Errorf("创建 Promtail 目录失败: %w", err)
|
||||
// 配置生成与上传(写 .new,两种托管方式共用;校验通过才替换正式配置)
|
||||
conf, err := d.prepareHostConfig(ctx, client, host, endpoint, mode == HostRunModeDocker)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 下载 Promtail
|
||||
d.log("info", fmt.Sprintf("下载 Promtail %s(%s)", version, arch), "")
|
||||
url := fmt.Sprintf("https://github.com/grafana/loki/releases/download/v%s/promtail-linux-%s.zip", version, arch)
|
||||
dl := fmt.Sprintf("cd /tmp && curl -fsSL -o p.zip '%s' && unzip -o p.zip -d /opt/promtail/ && chmod +x /opt/promtail/promtail-linux-%s", url, arch)
|
||||
if _, _, err := client.Execute(ctx, dl); err != nil {
|
||||
return fmt.Errorf("下载 Promtail 失败: %w", err)
|
||||
if mode == HostRunModeDocker {
|
||||
err := d.installHostDocker(ctx, client, conf, caps)
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
// 目标机拉不到镜像(内网/无外网)时,systemd 二进制托管是可行的退路;
|
||||
// 其它错误(配置非法、容器崩溃)必须原样抛出,不能被回退掩盖。
|
||||
if errors.Is(err, errImagePullFailed) && caps.HasSystemd {
|
||||
d.log("warn", fmt.Sprintf("容器方式不可用,回退为 systemd 二进制托管:%v", err), "")
|
||||
return d.installHostSystemd(ctx, client, conf, caps)
|
||||
}
|
||||
return err
|
||||
}
|
||||
client.Execute(ctx, "ln -sf /opt/promtail/promtail-linux-* /usr/local/bin/promtail 2>/dev/null || true")
|
||||
d.log("warn", "目标机没有可用的 Docker,回退为 systemd 二进制托管(跨发行版差异更大,建议先装 Docker)", "")
|
||||
return d.installHostSystemd(ctx, client, conf, caps)
|
||||
}
|
||||
|
||||
// 生成并上传配置
|
||||
// prepareHostConfig 探测目标机身份并渲染/上传 promtail.yaml.new。
|
||||
//
|
||||
// 三件事必须在安装期探测,不能写死:
|
||||
// - 真实主机名(promtail 不展开 ${HOSTNAME},写死会在 Loki 里留下一堆字面量标签)
|
||||
// - 系统日志时间戳格式(TencentOS 是 ISO8601,Debian 是传统 syslog)
|
||||
// - 系统日志路径(Ubuntu 只有 syslog/auth.log,没有 messages)
|
||||
func (d *CollectorDeployer) prepareHostConfig(ctx context.Context, client *SSHClient, host *models.Host, endpoint string, dockerMode bool) (string, error) {
|
||||
logPath := d.collector.LogPath
|
||||
if logPath == "" {
|
||||
logPath = "/var/log/*.log"
|
||||
}
|
||||
// 目标机真实身份与 syslog 格式只能在安装时探测(见 Bug3 / Bug4):
|
||||
// - host 标签写真实主机名,promtail 不做 ${HOSTNAME} 展开;
|
||||
// - 系统日志按实际格式(ISO8601 / 传统 syslog)选正则。
|
||||
// 推送地址纠正:Loki 的 listenIP 填成 0.0.0.0 时 endpoint 是必然连不上的
|
||||
// http://0.0.0.0:3100,采集器会 running 但一条日志都推不上去。
|
||||
hostIP := ""
|
||||
if host != nil {
|
||||
hostIP = host.HostIP
|
||||
}
|
||||
pushEndpoint, err := hostPushEndpoint(endpoint, hostIP, dockerMode)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if pushEndpoint != endpoint {
|
||||
d.log("warn", fmt.Sprintf("Loki 推送地址不可用(%s),已改为 %s —— 建议把 Loki 部署的监听地址改成实际 IP/域名", endpoint, pushEndpoint), "")
|
||||
}
|
||||
hostname := detectHostname(ctx, client, host)
|
||||
syslogFormat := detectSyslogFormat(ctx, client)
|
||||
d.log("info", fmt.Sprintf("目标机 %s,syslog 格式 %s", hostname, syslogFormat), "")
|
||||
|
||||
// 系统日志路径同样必须探测:Ubuntu 只有 syslog/auth.log,没有 messages
|
||||
sysLogPaths := detectSystemLogPaths(ctx, client)
|
||||
d.log("info", fmt.Sprintf("系统日志路径:%s", strings.Join(sysLogPaths, ",")), "")
|
||||
d.log("info", fmt.Sprintf("目标机 %s,syslog 格式 %s,系统日志 %s",
|
||||
hostname, syslogFormat, strings.Join(sysLogPaths, ",")), "")
|
||||
|
||||
conf, err := PromtailConfigForHost(HostPromtailOptions{
|
||||
Endpoint: endpoint,
|
||||
Endpoint: pushEndpoint,
|
||||
Cluster: "host",
|
||||
LabelsJSON: d.collector.Labels,
|
||||
Hostname: hostname,
|
||||
@@ -142,29 +171,127 @@ func (d *CollectorDeployer) installHost(ctx context.Context, endpoint string) er
|
||||
Jobs: d.collector.ScrapeJobs,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("生成 Promtail 配置失败(现有配置未改动): %w", err)
|
||||
return "", fmt.Errorf("生成 Promtail 配置失败(现有配置未改动): %w", err)
|
||||
}
|
||||
d.collector.ResolvedHostname = hostname
|
||||
d.collector.SyslogFormat = syslogFormat
|
||||
d.collector.SystemLogPaths = sysLogPaths
|
||||
|
||||
// 先写临时文件并做语法校验,通过后才替换正式配置 ——
|
||||
// 配置坏了会把正在跑的采集干掉,不能直接覆盖。
|
||||
d.log("info", "上传 Promtail 配置并校验", "")
|
||||
if _, _, err := client.Execute(ctx, fmt.Sprintf("cat > /etc/promtail/promtail.yaml.new << 'PROMTAIL_EOF'\n%s\nPROMTAIL_EOF", conf)); err != nil {
|
||||
return fmt.Errorf("上传配置失败: %w", err)
|
||||
// 先写 .new:配置坏了会把正在跑的采集干掉,不能直接覆盖正式配置
|
||||
d.log("info", "上传 Promtail 配置", "")
|
||||
if err := uploadDockerFile(ctx, client, promtailConfigPath+".new", conf, "644"); err != nil {
|
||||
return "", fmt.Errorf("上传配置失败: %w", err)
|
||||
}
|
||||
if out, _, err := client.Execute(ctx, "/usr/local/bin/promtail -check-syntax -config.file=/etc/promtail/promtail.yaml.new 2>&1"); err != nil {
|
||||
// 老版本 promtail 可能没有 -check-syntax:只有明确报配置错误才阻断
|
||||
return conf, nil
|
||||
}
|
||||
|
||||
// installHostDocker 以容器方式托管采集器(首选路径)。
|
||||
func (d *CollectorDeployer) installHostDocker(ctx context.Context, client *SSHClient, conf string, caps *HostCapabilities) error {
|
||||
name := promtailContainerName(d.collector.ID)
|
||||
image := ResolveImageFor("promtail", "grafana/promtail", DefaultPromtailVersion)
|
||||
|
||||
d.log("info", "拉取采集器镜像 "+image, "")
|
||||
if _, stderr, err := client.Execute(ctx, "docker pull "+image); err != nil {
|
||||
return fmt.Errorf("%w(目标机需能访问镜像仓库,可先在平台侧配置镜像加速): %v: %s",
|
||||
errImagePullFailed, err, truncateForMessage(strings.TrimSpace(stderr), 300))
|
||||
}
|
||||
|
||||
// 用容器自己校验配置:3.x 的 -check-syntax 能挡住路径/正则/缩进错误
|
||||
d.log("info", "校验 Promtail 配置", "")
|
||||
checkCmd := fmt.Sprintf("docker run --rm -v %s:%s:ro %s -check-syntax -config.file=%s.new 2>&1",
|
||||
promtailConfigDir, promtailConfigDir, image, promtailConfigPath)
|
||||
out, _, err := client.Execute(ctx, checkCmd)
|
||||
if err != nil {
|
||||
msg := strings.TrimSpace(out)
|
||||
if strings.Contains(msg, "flag provided but not defined") {
|
||||
d.log("info", "当前 Promtail 版本不支持 -check-syntax,跳过语法校验", "")
|
||||
} else {
|
||||
return fmt.Errorf("Promtail 配置校验未通过(现有采集未改动):%s", truncateForMessage(msg, 600))
|
||||
}
|
||||
}
|
||||
if err := promoteHostConfig(ctx, client); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 从 systemd 迁到容器:不先停旧服务就会两边同时采同一批文件(日志重复一倍)
|
||||
if out, _, err := client.Execute(ctx, "systemctl is-active "+promtailServiceName+" 2>/dev/null"); err == nil && strings.TrimSpace(out) == "active" {
|
||||
client.Execute(ctx, "systemctl stop "+promtailServiceName+" 2>/dev/null; systemctl disable "+promtailServiceName+" 2>/dev/null")
|
||||
d.log("info", "已停止旧的 systemd promtail 服务(改为容器托管,避免重复采集)", "")
|
||||
}
|
||||
|
||||
client.Execute(ctx, "docker rm -f "+name+" >/dev/null 2>&1 || true")
|
||||
|
||||
mounts := []string{
|
||||
"-v " + promtailConfigDir + ":" + promtailConfigDir + ":ro",
|
||||
"-v " + promtailPositionsDir + ":" + promtailPositionsDir,
|
||||
"-v /var/log:/var/log:ro",
|
||||
}
|
||||
if caps.HasJournald {
|
||||
// 采 systemd journal 时需要(Ubuntu 最小安装无 rsyslog 的场景)
|
||||
mounts = append(mounts,
|
||||
"-v /run/log/journal:/run/log/journal:ro",
|
||||
"-v /etc/machine-id:/etc/machine-id:ro")
|
||||
}
|
||||
// SELinux Enforcing 时 bind mount 会被拒绝(容器里读不到 /var/log);
|
||||
// 采集器容器不承载多租户隔离,直接关标签检查,比给宿主机目录重新打标签安全。
|
||||
secOpt := ""
|
||||
if strings.EqualFold(caps.SELinux, "Enforcing") {
|
||||
secOpt = " --security-opt label=disable"
|
||||
}
|
||||
runCmd := fmt.Sprintf(
|
||||
"docker run -d --name %s --restart=always --memory=256m --cpus=1 "+
|
||||
"--log-opt max-size=10m --log-opt max-file=3%s %s %s -config.file=%s",
|
||||
name, secOpt, strings.Join(mounts, " "), image, promtailConfigPath)
|
||||
d.log("info", "启动采集器容器 "+name, "")
|
||||
if _, stderr, err := client.Execute(ctx, runCmd); err != nil {
|
||||
return fmt.Errorf("启动采集器容器失败: %v: %s", err, truncateForMessage(strings.TrimSpace(stderr), 400))
|
||||
}
|
||||
|
||||
// docker run 返回 0 也可能是「起来就崩」——必须查容器状态与重启次数
|
||||
if err := waitContainerRunning(ctx, client, name, 30*time.Second); err != nil {
|
||||
logs, _, _ := client.Execute(ctx, fmt.Sprintf("docker logs --tail 30 %s 2>&1", name))
|
||||
return fmt.Errorf("采集器容器未正常运行:%v\n最近日志:\n%s", err, truncateForMessage(strings.TrimSpace(logs), 900))
|
||||
}
|
||||
|
||||
d.collector.RunMode = HostRunModeDocker
|
||||
d.collector.Config = conf
|
||||
d.log("success", fmt.Sprintf("Promtail 已以容器方式运行(%s,--restart=always)", name), "")
|
||||
return nil
|
||||
}
|
||||
|
||||
// installHostSystemd 无 Docker 环境下的兜底路径:二进制 + systemd 单元。
|
||||
func (d *CollectorDeployer) installHostSystemd(ctx context.Context, client *SSHClient, conf string, caps *HostCapabilities) error {
|
||||
arch, err := normalizeArch(caps.Arch)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 确定 Promtail 版本(优先使用关联的 Loki 版本)
|
||||
version := "3.0.0"
|
||||
if d.loki != nil && d.loki.Version != "" {
|
||||
version = d.loki.Version
|
||||
}
|
||||
d.log("info", fmt.Sprintf("下载 Promtail %s(%s)", version, arch), "")
|
||||
url := fmt.Sprintf("https://github.com/grafana/loki/releases/download/v%s/promtail-linux-%s.zip", version, arch)
|
||||
dl := fmt.Sprintf("cd /tmp && curl -fsSL -o p.zip '%s' && unzip -o p.zip -d /opt/promtail/ && chmod +x /opt/promtail/promtail-linux-%s", url, arch)
|
||||
if _, _, err := client.Execute(ctx, dl); err != nil {
|
||||
return fmt.Errorf("下载 Promtail 失败: %w", err)
|
||||
}
|
||||
client.Execute(ctx, "ln -sf /opt/promtail/promtail-linux-* /usr/local/bin/promtail 2>/dev/null || true")
|
||||
|
||||
// 语法校验通过才替换正式配置
|
||||
d.log("info", "校验 Promtail 配置", "")
|
||||
if out, _, err := client.Execute(ctx, "/usr/local/bin/promtail -check-syntax -config.file="+promtailConfigPath+".new 2>&1"); err != nil {
|
||||
msg := strings.TrimSpace(out)
|
||||
// 老版本 promtail 可能没有 -check-syntax:只有明确报配置错误才阻断
|
||||
if strings.Contains(msg, "flag provided but not defined") {
|
||||
d.log("info", "当前 promtail 不支持 -check-syntax,跳过语法校验", "")
|
||||
} else {
|
||||
return fmt.Errorf("Promtail 配置校验未通过(现有配置未改动):%s", truncateForMessage(msg, 600))
|
||||
}
|
||||
}
|
||||
if _, _, err := client.Execute(ctx, "mv -f /etc/promtail/promtail.yaml.new /etc/promtail/promtail.yaml"); err != nil {
|
||||
return fmt.Errorf("替换 Promtail 配置失败: %w", err)
|
||||
if err := promoteHostConfig(ctx, client); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// systemd 服务(生产级)
|
||||
@@ -177,7 +304,7 @@ After=network-online.target
|
||||
Type=simple
|
||||
User=root
|
||||
Group=root
|
||||
ExecStart=/usr/local/bin/promtail -config.file=/etc/promtail/promtail.yaml
|
||||
ExecStart=/usr/local/bin/promtail -config.file=` + promtailConfigPath + `
|
||||
ExecReload=/bin/kill -HUP $MAINPID
|
||||
Restart=always
|
||||
RestartSec=10
|
||||
@@ -189,18 +316,18 @@ StandardError=append:/var/log/promtail.log
|
||||
WantedBy=multi-user.target
|
||||
`
|
||||
d.log("info", "安装 systemd 服务", "")
|
||||
if _, _, err := client.Execute(ctx, fmt.Sprintf("cat > /etc/systemd/system/promtail.service << 'EOF'\n%s\nEOF", service)); err != nil {
|
||||
if err := uploadDockerFile(ctx, client, "/etc/systemd/system/"+promtailServiceName+".service", service, "644"); err != nil {
|
||||
return fmt.Errorf("创建 systemd 服务失败: %w", err)
|
||||
}
|
||||
if _, _, err := client.Execute(ctx, "systemctl daemon-reload && systemctl enable promtail && systemctl restart promtail"); err != nil {
|
||||
if _, _, err := client.Execute(ctx, "systemctl daemon-reload && systemctl enable "+promtailServiceName+" && systemctl restart "+promtailServiceName); err != nil {
|
||||
return fmt.Errorf("启动 Promtail 失败: %w", err)
|
||||
}
|
||||
|
||||
// 必须校验服务真的起来了:
|
||||
// 原来只判断 systemctl 命令有没有执行错误,结果 Promtail 因配置/参数错误
|
||||
// 崩溃重启 40+ 次,平台仍然显示"采集器已部署",用户以为采集是好的。
|
||||
if err := waitServiceActive(ctx, client, "promtail", 20*time.Second); err != nil {
|
||||
out, _, _ := client.Execute(ctx, "journalctl -u promtail -n 15 --no-pager 2>/dev/null | tail -15")
|
||||
// 崩溃重启 40+ 次,平台仍然显示「采集器已部署」,用户以为采集是好的。
|
||||
if err := waitServiceActive(ctx, client, promtailServiceName, 20*time.Second); err != nil {
|
||||
out, _, _ := client.Execute(ctx, "journalctl -u "+promtailServiceName+" -n 15 --no-pager 2>/dev/null | tail -15")
|
||||
tail := strings.TrimSpace(out)
|
||||
if tail == "" {
|
||||
out, _, _ = client.Execute(ctx, "tail -10 /var/log/promtail.log 2>/dev/null")
|
||||
@@ -209,9 +336,13 @@ WantedBy=multi-user.target
|
||||
return fmt.Errorf("Promtail 启动后未处于运行状态:%v\n最近日志:\n%s", err, truncateForMessage(tail, 900))
|
||||
}
|
||||
|
||||
// 反向清理:同名容器若存在(从 docker 模式切回来),停掉避免重复采集
|
||||
client.Execute(ctx, "docker rm -f "+promtailContainerName(d.collector.ID)+" >/dev/null 2>&1 || true")
|
||||
|
||||
// 保存配置快照
|
||||
d.collector.RunMode = HostRunModeSystemd
|
||||
d.collector.Config = conf
|
||||
d.log("success", "Promtail 部署完成", "")
|
||||
d.log("success", "Promtail 已以 systemd 服务方式运行", "")
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -291,10 +422,19 @@ func (d *CollectorDeployer) uninstallHost(ctx context.Context) error {
|
||||
return err
|
||||
}
|
||||
defer client.Close()
|
||||
client.Execute(ctx, "systemctl stop promtail 2>/dev/null || true")
|
||||
client.Execute(ctx, "systemctl disable promtail 2>/dev/null || true")
|
||||
client.Execute(ctx, "rm -rf /etc/systemd/system/promtail.service /usr/local/bin/promtail /etc/promtail /opt/promtail /var/lib/promtail")
|
||||
client.Execute(ctx, "systemctl daemon-reload")
|
||||
|
||||
name := promtailContainerName(d.collector.ID)
|
||||
// 两种托管方式都清一遍:RunMode 对老记录是空的,且用户可能手工装过另一种。
|
||||
// 顺序固定:先停容器/服务,再删文件 —— 否则进程还在跑就没了配置。
|
||||
if out, _, err := client.Execute(ctx, "docker rm -f "+name+" 2>&1"); err == nil {
|
||||
d.log("info", "已删除采集器容器 "+strings.TrimSpace(out), "")
|
||||
}
|
||||
client.Execute(ctx, "systemctl stop "+promtailServiceName+" 2>/dev/null; systemctl disable "+promtailServiceName+" 2>/dev/null")
|
||||
client.Execute(ctx, "rm -f /etc/systemd/system/"+promtailServiceName+".service")
|
||||
client.Execute(ctx, "systemctl daemon-reload 2>/dev/null || true")
|
||||
client.Execute(ctx, "rm -rf "+promtailConfigDir+" /opt/promtail "+promtailPositionsDir+" /usr/local/bin/promtail")
|
||||
d.log("info", "已清理采集器配置、positions 与二进制", "")
|
||||
d.collector.RunMode = ""
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,409 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
corev1 "k8s.io/api/core/v1"
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
"k8s.io/client-go/kubernetes"
|
||||
|
||||
"lokistack/models"
|
||||
)
|
||||
|
||||
// 主机采集器的两种托管方式。
|
||||
//
|
||||
// 为什么要落库:安装/升级/卸载必须走**同一条**路径。不记录的话,卸载时不知道该
|
||||
// 删容器还是删 systemd 单元 → 留下孤儿进程继续采日志(重复数据 + 无法收敛)。
|
||||
const (
|
||||
HostRunModeDocker = "docker"
|
||||
HostRunModeSystemd = "systemd"
|
||||
)
|
||||
|
||||
// DefaultPromtailVersion 采集器容器镜像版本(与 DaemonSet 模板保持一致)。
|
||||
const DefaultPromtailVersion = "3.4.0"
|
||||
|
||||
const (
|
||||
promtailServiceName = "promtail"
|
||||
promtailConfigDir = "/etc/promtail"
|
||||
promtailConfigPath = promtailConfigDir + "/promtail.yaml"
|
||||
promtailPositionsDir = "/var/lib/promtail"
|
||||
)
|
||||
|
||||
func promtailContainerName(id uint) string { return fmt.Sprintf("lokistack-promtail-%d", id) }
|
||||
|
||||
// hostPushEndpoint 把「Loki 监听地址」纠正成「采集器真正能连的推送地址」。
|
||||
//
|
||||
// 两种必须纠正的情况(本会话实测踩到的):
|
||||
//
|
||||
// ① 用户在主机部署 Loki 时把 listenIP 填成 0.0.0.0 / ::
|
||||
// → 部署记录里的 endpoint 变成 http://0.0.0.0:3100,采集器照抄过去推
|
||||
// → connect: connection refused,日志静默丢失(容器/serve 都是 running,最难查)
|
||||
// ② 容器模式下的回环地址
|
||||
// → 容器用 bridge 网络,127.0.0.1 指的是容器自己,不是宿主机
|
||||
// (systemd 模式不受影响,所以只在 docker 模式下改)
|
||||
//
|
||||
// 纠正不了(没有主机 IP 可替换)时明确报错,不返回一个必然失败的地址。
|
||||
func hostPushEndpoint(endpoint, hostIP string, dockerMode bool) (string, error) {
|
||||
raw := strings.TrimSpace(endpoint)
|
||||
if raw == "" {
|
||||
return "", fmt.Errorf("缺少 Loki 推送地址(endpoint)")
|
||||
}
|
||||
u, err := url.Parse(raw)
|
||||
if err != nil || u.Host == "" {
|
||||
return raw, nil // 非标准形态(如 k8s svc 短名)不做处理
|
||||
}
|
||||
host := u.Hostname()
|
||||
wildcard := host == "0.0.0.0" || host == "::" || host == "[::]"
|
||||
loopback := host == "localhost" || host == "127.0.0.1" || host == "::1"
|
||||
if !wildcard && !(dockerMode && loopback) {
|
||||
return raw, nil
|
||||
}
|
||||
ip := strings.TrimSpace(hostIP)
|
||||
if ip == "" || net.ParseIP(ip) == nil {
|
||||
return "", fmt.Errorf("Loki 推送地址是 %s(%s),但取不到目标主机 IP 用于替换:"+
|
||||
"请把 Loki 部署的监听地址改成实际可访问的 IP 或域名", raw, map[bool]string{true: "通配地址无法作为推送目标", false: "容器内 127.0.0.1 指向容器自身"}[wildcard])
|
||||
}
|
||||
port := u.Port()
|
||||
if port == "" {
|
||||
port = "3100"
|
||||
}
|
||||
u.Host = net.JoinHostPort(ip, port)
|
||||
return u.String(), nil
|
||||
}
|
||||
|
||||
// errImagePullFailed 拉不到采集器镜像(目标机访问不了镜像仓库)。
|
||||
// 单独成哨兵错误:这种情况可以退回 systemd 二进制托管,而配置错误、容器起不来
|
||||
// 这类问题不该回退(回退只会把真实错误藏起来)。
|
||||
var errImagePullFailed = errors.New("拉取采集器镜像失败")
|
||||
|
||||
// HostCapabilities 是目标主机的托管能力探测结果(一次 SSH 往返拿全)。
|
||||
type HostCapabilities struct {
|
||||
Arch string // x86_64 / aarch64 …
|
||||
InitName string // systemd / init / …
|
||||
HasSystemd bool // /run/systemd/system 存在(systemd 正在跑,不是只装了命令)
|
||||
HasDocker bool // 有 docker 命令
|
||||
DockerUsable bool // docker info 成功(daemon 在跑 + 当前用户有权限)
|
||||
DockerVersion string
|
||||
DockerEnabled string // enabled / disabled / unknown
|
||||
SELinux string // Enforcing / Permissive / Disabled / unknown
|
||||
HasJournald bool // 存在持久化或运行时 journal 目录
|
||||
}
|
||||
|
||||
// DockerSummary 供操作日志展示(别在日志里输出空字符串)。
|
||||
func (c *HostCapabilities) DockerSummary() string {
|
||||
if !c.HasDocker {
|
||||
return "未安装"
|
||||
}
|
||||
if !c.DockerUsable {
|
||||
if c.DockerVersion != "" {
|
||||
return "已安装但不可用(daemon 未运行或无权限)"
|
||||
}
|
||||
return "已安装但不可用"
|
||||
}
|
||||
desc := c.DockerVersion
|
||||
if desc == "" {
|
||||
desc = "可用"
|
||||
}
|
||||
return desc + "(开机自启 " + c.DockerEnabled + ")"
|
||||
}
|
||||
|
||||
// detectHostCapabilities 探测目标机能否用 docker / systemd 托管长驻进程。
|
||||
//
|
||||
// 一次合并命令的原因:每次 SSH 往返都有 100~300ms 开销,装机流程本来就要跑十几步,
|
||||
// 逐条探测会让「点部署后等很久」的体感更差。
|
||||
func detectHostCapabilities(ctx context.Context, client *SSHClient) *HostCapabilities {
|
||||
caps := &HostCapabilities{InitName: "unknown", SELinux: "unknown", DockerEnabled: "unknown"}
|
||||
script := `echo "ARCH=$(uname -m 2>/dev/null)"
|
||||
echo "INIT=$(ps -p 1 -o comm= 2>/dev/null | tr -d ' ')"
|
||||
[ -d /run/systemd/system ] && echo "SYSTEMD=1" || echo "SYSTEMD=0"
|
||||
if command -v docker >/dev/null 2>&1; then
|
||||
echo "DOCKER=1"
|
||||
echo "DOCKER_VERSION=$(docker version --format '{{.Server.Version}}' 2>/dev/null)"
|
||||
docker info >/dev/null 2>&1 && echo "DOCKER_USABLE=1" || echo "DOCKER_USABLE=0"
|
||||
echo "DOCKER_ENABLED=$(systemctl is-enabled docker 2>/dev/null || echo unknown)"
|
||||
else
|
||||
echo "DOCKER=0"
|
||||
echo "DOCKER_USABLE=0"
|
||||
fi
|
||||
echo "SELINUX=$(getenforce 2>/dev/null || echo unknown)"
|
||||
if [ -d /run/log/journal ] || [ -d /var/log/journal ]; then echo "JOURNALD=1"; else echo "JOURNALD=0"; fi`
|
||||
out, _, err := client.Execute(ctx, script)
|
||||
if err != nil && strings.TrimSpace(out) == "" {
|
||||
return caps
|
||||
}
|
||||
for _, line := range strings.Split(out, "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
key, value, ok := strings.Cut(line, "=")
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
switch key {
|
||||
case "ARCH":
|
||||
caps.Arch = value
|
||||
case "INIT":
|
||||
if value != "" {
|
||||
caps.InitName = value
|
||||
}
|
||||
case "SYSTEMD":
|
||||
caps.HasSystemd = value == "1"
|
||||
case "DOCKER":
|
||||
caps.HasDocker = value == "1"
|
||||
case "DOCKER_VERSION":
|
||||
caps.DockerVersion = value
|
||||
case "DOCKER_USABLE":
|
||||
caps.DockerUsable = value == "1"
|
||||
case "DOCKER_ENABLED":
|
||||
if value != "" {
|
||||
caps.DockerEnabled = value
|
||||
}
|
||||
case "SELINUX":
|
||||
if value != "" {
|
||||
caps.SELinux = value
|
||||
}
|
||||
case "JOURNALD":
|
||||
caps.HasJournald = value == "1"
|
||||
}
|
||||
}
|
||||
return caps
|
||||
}
|
||||
|
||||
// normalizeArch 把 uname -m 转成发行包/镜像使用的架构名。
|
||||
func normalizeArch(arch string) (string, error) {
|
||||
switch strings.TrimSpace(arch) {
|
||||
case "x86_64", "amd64":
|
||||
return "amd64", nil
|
||||
case "aarch64", "arm64":
|
||||
return "arm64", nil
|
||||
}
|
||||
return "", fmt.Errorf("不支持的主机架构: %s", arch)
|
||||
}
|
||||
|
||||
// chooseHostRunMode 决定托管方式:docker 优先 → systemd 兜底 → 都没有则明确报错。
|
||||
//
|
||||
// 抽成纯函数是为了能测:这条判断决定「卸载时要删容器还是删 unit」,
|
||||
// 判错的后果是孤儿进程继续采日志(重复数据)或卸载残留,靠人工回归太贵。
|
||||
func chooseHostRunMode(caps *HostCapabilities) (string, error) {
|
||||
if caps == nil {
|
||||
return "", fmt.Errorf("缺少目标机能力探测结果")
|
||||
}
|
||||
if caps.DockerUsable {
|
||||
return HostRunModeDocker, nil
|
||||
}
|
||||
if caps.HasSystemd {
|
||||
return HostRunModeSystemd, nil
|
||||
}
|
||||
return "", fmt.Errorf("目标机既没有可用的 Docker(docker info 失败)也没有 systemd,无法可靠托管采集进程:" +
|
||||
"请先在目标机安装并启动 Docker 后重试。平台不会以裸进程方式运行 promtail(主机重启后采集会静默中断)")
|
||||
}
|
||||
|
||||
func ensureHostPromtailDirs(ctx context.Context, client *SSHClient) error {
|
||||
if _, _, err := client.Execute(ctx, "mkdir -p "+promtailConfigDir+" "+promtailPositionsDir); err != nil {
|
||||
return fmt.Errorf("创建 Promtail 目录失败: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// promoteHostConfig 把校验通过的 .new 覆盖为正式配置。
|
||||
func promoteHostConfig(ctx context.Context, client *SSHClient) error {
|
||||
if _, _, err := client.Execute(ctx, "mv -f "+promtailConfigPath+".new "+promtailConfigPath); err != nil {
|
||||
return fmt.Errorf("替换 Promtail 配置失败: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// waitContainerRunning 轮询容器真实状态。
|
||||
//
|
||||
// `docker run -d` 返回 0 **不代表容器在跑**(镜像参数错会立刻退出)。
|
||||
// 这里同时看 Status 与 RestartCount:RestartCount 持续增长 = 崩溃循环,
|
||||
// 必须立即报错并带回 docker logs —— 等价于 systemd 侧 waitServiceActive 的防护。
|
||||
func waitContainerRunning(ctx context.Context, client *SSHClient, name string, timeout time.Duration) error {
|
||||
deadline := time.Now().Add(timeout)
|
||||
last := ""
|
||||
for time.Now().Before(deadline) {
|
||||
out, _, _ := client.Execute(ctx, fmt.Sprintf("docker inspect -f '{{.State.Status}}|{{.RestartCount}}' %s 2>/dev/null", name))
|
||||
last = strings.TrimSpace(out)
|
||||
status, restarts, ok := strings.Cut(last, "|")
|
||||
if ok {
|
||||
status = strings.TrimSpace(status)
|
||||
restarts = strings.TrimSpace(restarts)
|
||||
if status == "running" && restarts == "0" {
|
||||
return nil
|
||||
}
|
||||
if restarts != "" && restarts != "0" {
|
||||
return fmt.Errorf("容器 %s 处于重启循环(Status=%s, RestartCount=%s)", name, status, restarts)
|
||||
}
|
||||
}
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
case <-time.After(2 * time.Second):
|
||||
}
|
||||
}
|
||||
if last == "" {
|
||||
last = "容器不存在"
|
||||
}
|
||||
return fmt.Errorf("容器 %s %s 内未进入 running 且无重启(当前 %s)", name, timeout, last)
|
||||
}
|
||||
|
||||
// ===== 采集器日志(Web 上直接看,不用登机器) =====
|
||||
|
||||
// CollectorLogs 返回采集器最近的运行日志。
|
||||
//
|
||||
// 主机模式按托管方式自动选入口:容器 → `docker logs`;systemd → journalctl / 文件。
|
||||
// K8s 模式读 DaemonSet/采集 Pod 的容器日志。
|
||||
func (s *CollectorService) CollectorLogs(id uint, tail int) (map[string]interface{}, error) {
|
||||
c, err := s.Get(id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if tail <= 0 {
|
||||
tail = 200
|
||||
}
|
||||
if tail > 2000 {
|
||||
tail = 2000
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
switch c.Type {
|
||||
case "host-promtail":
|
||||
return hostCollectorLogs(ctx, c, tail)
|
||||
case "k8s-daemonset", "k8s-fluentbit-sidecar":
|
||||
return k8sCollectorLogs(ctx, c, tail)
|
||||
}
|
||||
return nil, fmt.Errorf("不支持的采集器类型: %s", c.Type)
|
||||
}
|
||||
|
||||
func hostCollectorLogs(ctx context.Context, c *models.Collector, tail int) (map[string]interface{}, error) {
|
||||
if c.Host == nil {
|
||||
return nil, fmt.Errorf("该采集器未关联主机,无法读取日志")
|
||||
}
|
||||
client, err := NewSSHClientWithAuth(HostSSHAuth(c.Host))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer client.Close()
|
||||
|
||||
name := promtailContainerName(c.ID)
|
||||
// ① 容器托管(首选):docker logs 自带轮转,不需要额外清理
|
||||
if _, _, err := client.Execute(ctx, "docker container inspect "+name+" >/dev/null 2>&1"); err == nil {
|
||||
out, stderr, _ := client.Execute(ctx, fmt.Sprintf("docker logs --tail %d --timestamps %s 2>&1", tail, name))
|
||||
return map[string]interface{}{
|
||||
"source": "docker",
|
||||
"sourceLabel": "Docker 容器 " + name,
|
||||
"content": strings.TrimSpace(out + stderr),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// ② systemd 托管(无 docker 的兜底路径,或历史安装)
|
||||
if _, _, err := client.Execute(ctx, "[ -f /etc/systemd/system/"+promtailServiceName+".service ]"); err == nil {
|
||||
out, _, _ := client.Execute(ctx, fmt.Sprintf("journalctl -u %s -n %d --no-pager 2>/dev/null", promtailServiceName, tail))
|
||||
content := strings.TrimSpace(out)
|
||||
if content == "" {
|
||||
out, _, _ = client.Execute(ctx, fmt.Sprintf("tail -n %d /var/log/promtail.log 2>/dev/null", tail))
|
||||
content = strings.TrimSpace(out)
|
||||
}
|
||||
return map[string]interface{}{
|
||||
"source": "systemd",
|
||||
"sourceLabel": "systemd 服务 " + promtailServiceName,
|
||||
"content": content,
|
||||
}, nil
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("目标机 %s 上既没有采集器容器 %s,也没有 %s systemd 服务(可能尚未部署或已被手工清理)",
|
||||
c.Host.HostIP, name, promtailServiceName)
|
||||
}
|
||||
|
||||
func k8sCollectorLogs(ctx context.Context, c *models.Collector, tail int) (map[string]interface{}, error) {
|
||||
if c.Cluster == nil {
|
||||
return nil, fmt.Errorf("该采集器未关联集群,无法读取日志")
|
||||
}
|
||||
ns := c.Namespace
|
||||
if ns == "" && c.LokiDeployment != nil {
|
||||
ns = c.LokiDeployment.Namespace
|
||||
}
|
||||
if ns == "" {
|
||||
ns = "log"
|
||||
}
|
||||
client, err := clusterClient(c.Cluster, 30*time.Second)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Sidecar 模式的日志在业务 Pod 的 fluent-bit 容器里,不是独立的 DaemonSet
|
||||
if c.Type == "k8s-fluentbit-sidecar" {
|
||||
return sidecarCollectorLogs(ctx, client, ns, c.TargetDeployment, tail)
|
||||
}
|
||||
|
||||
pods, err := client.CoreV1().Pods(ns).List(ctx, metav1.ListOptions{LabelSelector: "app=promtail"})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("读取采集器 Pod 失败(检查集群凭证是否具备 pods list/log 权限): %w", err)
|
||||
}
|
||||
if len(pods.Items) == 0 {
|
||||
return nil, fmt.Errorf("命名空间 %s 中没有找到 promtail Pod(采集器可能未部署成功)", ns)
|
||||
}
|
||||
return podContainerLogs(ctx, client, ns, runningPodName(pods.Items), "promtail", tail)
|
||||
}
|
||||
|
||||
// runningPodName 优先挑 Running 的 Pod:DaemonSet 里常有刚重建的 Pending 实例,
|
||||
// 读它的日志只会得到一行「container is waiting to start」。
|
||||
func runningPodName(pods []corev1.Pod) string {
|
||||
for _, p := range pods {
|
||||
if p.Status.Phase == corev1.PodRunning {
|
||||
return p.Name
|
||||
}
|
||||
}
|
||||
if len(pods) > 0 {
|
||||
return pods[0].Name
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func podContainerLogs(ctx context.Context, client kubernetes.Interface, ns, pod, container string, tail int) (map[string]interface{}, error) {
|
||||
if pod == "" {
|
||||
return nil, fmt.Errorf("命名空间 %s 中没有可读取日志的采集 Pod", ns)
|
||||
}
|
||||
lines := int64(tail)
|
||||
raw, err := client.CoreV1().Pods(ns).GetLogs(pod, &corev1.PodLogOptions{TailLines: &lines, Container: container}).DoRaw(ctx)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("读取 Pod %s/%s 的 %s 容器日志失败: %w", ns, pod, container, err)
|
||||
}
|
||||
return map[string]interface{}{
|
||||
"source": "kubernetes",
|
||||
"sourceLabel": fmt.Sprintf("Pod %s/%s(容器 %s)", ns, pod, container),
|
||||
"content": strings.TrimSpace(string(raw)),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// sidecarCollectorLogs 找业务 Deployment 的 Pod,读它里面 fluent-bit 容器的日志。
|
||||
func sidecarCollectorLogs(ctx context.Context, client kubernetes.Interface, ns, deployment string, tail int) (map[string]interface{}, error) {
|
||||
if deployment == "" {
|
||||
return nil, fmt.Errorf("该 Sidecar 采集器缺少目标 Deployment,无法定位日志")
|
||||
}
|
||||
pods, err := client.CoreV1().Pods(ns).List(ctx, metav1.ListOptions{})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("读取命名空间 %s 的 Pod 失败(检查集群凭证权限): %w", ns, err)
|
||||
}
|
||||
matched := make([]corev1.Pod, 0, len(pods.Items))
|
||||
for _, p := range pods.Items {
|
||||
if !strings.HasPrefix(p.Name, deployment) {
|
||||
continue
|
||||
}
|
||||
for _, ct := range p.Spec.Containers {
|
||||
if ct.Name == "fluent-bit" {
|
||||
matched = append(matched, p)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(matched) == 0 {
|
||||
return nil, fmt.Errorf("命名空间 %s 中 %s 的 Pod 里没有 fluent-bit 容器(Sidecar 可能未注入成功)", ns, deployment)
|
||||
}
|
||||
return podContainerLogs(ctx, client, ns, runningPodName(matched), "fluent-bit", tail)
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// 托管方式选择是「卸载时删容器还是删 unit」的唯一依据,四条分支都要钉死。
|
||||
func TestChooseHostRunMode(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
caps *HostCapabilities
|
||||
want string
|
||||
wantErr string
|
||||
}{
|
||||
{
|
||||
name: "docker 可用 → 容器(即使同时有 systemd 也优先容器)",
|
||||
caps: &HostCapabilities{HasDocker: true, DockerUsable: true, HasSystemd: true},
|
||||
want: HostRunModeDocker,
|
||||
},
|
||||
{
|
||||
name: "装了 docker 但 daemon 没起来 → 回退 systemd",
|
||||
caps: &HostCapabilities{HasDocker: true, DockerUsable: false, HasSystemd: true},
|
||||
want: HostRunModeSystemd,
|
||||
},
|
||||
{
|
||||
name: "没有 docker 也没有 systemd → 明确报错,不裸跑",
|
||||
caps: &HostCapabilities{HasDocker: false, DockerUsable: false, HasSystemd: false},
|
||||
wantErr: "既没有可用的 Docker",
|
||||
},
|
||||
{
|
||||
name: "探测结果缺失 → 报错而不是猜一个",
|
||||
caps: nil,
|
||||
wantErr: "缺少目标机能力探测结果",
|
||||
},
|
||||
}
|
||||
for _, c := range cases {
|
||||
got, err := chooseHostRunMode(c.caps)
|
||||
if c.wantErr != "" {
|
||||
if err == nil || !strings.Contains(err.Error(), c.wantErr) {
|
||||
t.Fatalf("%s: 期望报错含 %q,实际 got=%q err=%v", c.name, c.wantErr, got, err)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("%s: 不应报错,实际 %v", c.name, err)
|
||||
}
|
||||
if got != c.want {
|
||||
t.Fatalf("%s: 期望 %q,实际 %q", c.name, c.want, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeArch(t *testing.T) {
|
||||
for in, want := range map[string]string{
|
||||
"x86_64": "amd64", "amd64": "amd64", "aarch64": "arm64", "arm64": "arm64",
|
||||
} {
|
||||
got, err := normalizeArch(in)
|
||||
if err != nil || got != want {
|
||||
t.Fatalf("%s → %q (err=%v),期望 %q", in, got, err, want)
|
||||
}
|
||||
}
|
||||
if _, err := normalizeArch("mips64"); err == nil {
|
||||
t.Fatal("未知架构应报错")
|
||||
}
|
||||
}
|
||||
|
||||
// 容器名必须带上采集器 ID:同一台机器可能装多个主机采集器,
|
||||
// 用固定名字会互相覆盖(第二个部署把第一个容器删掉)。
|
||||
func TestPromtailContainerNameIsPerCollector(t *testing.T) {
|
||||
a, b := promtailContainerName(2), promtailContainerName(3)
|
||||
if a == b {
|
||||
t.Fatalf("不同采集器不能共用容器名: %s", a)
|
||||
}
|
||||
if !strings.HasPrefix(a, "lokistack-promtail-") {
|
||||
t.Fatalf("容器名不符合约定: %s", a)
|
||||
}
|
||||
}
|
||||
|
||||
// 探测摘要要能一眼区分「没装 docker」和「装了但起不来」——这两种处置完全不同。
|
||||
func TestDockerSummaryDistinguishesStates(t *testing.T) {
|
||||
missing := (&HostCapabilities{}).DockerSummary()
|
||||
broken := (&HostCapabilities{HasDocker: true}).DockerSummary()
|
||||
ok := (&HostCapabilities{HasDocker: true, DockerUsable: true, DockerVersion: "27.5.1", DockerEnabled: "enabled"}).DockerSummary()
|
||||
|
||||
if !strings.Contains(missing, "未安装") {
|
||||
t.Fatalf("未安装 docker 的摘要不对: %s", missing)
|
||||
}
|
||||
if !strings.Contains(broken, "不可用") || strings.Contains(broken, "未安装") {
|
||||
t.Fatalf("装了但不可用的摘要不对: %s", broken)
|
||||
}
|
||||
if !strings.Contains(ok, "27.5.1") || !strings.Contains(ok, "enabled") {
|
||||
t.Fatalf("可用状态的摘要不对: %s", ok)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// 推送地址纠正:这是「采集器 running 但一条日志都进不去」的根因防护。
|
||||
func TestHostPushEndpoint(t *testing.T) {
|
||||
const ip = "172.19.16.8"
|
||||
cases := []struct {
|
||||
name string
|
||||
endpoint string
|
||||
hostIP string
|
||||
dockerMode bool
|
||||
want string
|
||||
wantErr string
|
||||
}{
|
||||
{
|
||||
name: "正常 IP 不动",
|
||||
endpoint: "http://172.19.16.8:3100",
|
||||
hostIP: ip,
|
||||
want: "http://172.19.16.8:3100",
|
||||
},
|
||||
{
|
||||
name: "k8s svc 地址不动(命名空间短名照原样)",
|
||||
endpoint: "http://loki.log.svc.cluster.local:3100",
|
||||
hostIP: ip,
|
||||
want: "http://loki.log.svc.cluster.local:3100",
|
||||
},
|
||||
{
|
||||
// 本会话实测踩到:listenIP 填 0.0.0.0 → endpoint 变成 0.0.0.0:3100 → 推送被拒
|
||||
name: "通配地址 0.0.0.0 换成真实主机 IP(两种模式都要换)",
|
||||
endpoint: "http://0.0.0.0:3100",
|
||||
hostIP: ip,
|
||||
want: "http://172.19.16.8:3100",
|
||||
},
|
||||
{
|
||||
name: "容器模式下 127.0.0.1 必须换成主机 IP(bridge 里指向容器自己)",
|
||||
endpoint: "http://127.0.0.1:3100",
|
||||
hostIP: ip,
|
||||
dockerMode: true,
|
||||
want: "http://172.19.16.8:3100",
|
||||
},
|
||||
{
|
||||
name: "systemd 模式下 127.0.0.1 保持原样(同机部署是合法的)",
|
||||
endpoint: "http://127.0.0.1:3100",
|
||||
hostIP: ip,
|
||||
want: "http://127.0.0.1:3100",
|
||||
},
|
||||
{
|
||||
name: "容器模式下 localhost 同样换掉",
|
||||
endpoint: "http://localhost:3100",
|
||||
hostIP: ip,
|
||||
dockerMode: true,
|
||||
want: "http://172.19.16.8:3100",
|
||||
},
|
||||
{
|
||||
name: "缺端口时补 3100",
|
||||
endpoint: "http://0.0.0.0",
|
||||
hostIP: ip,
|
||||
want: "http://172.19.16.8:3100",
|
||||
},
|
||||
{
|
||||
name: "空 endpoint 直接报错",
|
||||
endpoint: " ",
|
||||
hostIP: ip,
|
||||
wantErr: "缺少 Loki 推送地址",
|
||||
},
|
||||
{
|
||||
name: "拿不到主机 IP 时明确报错,而不是返回必然失败的地址",
|
||||
endpoint: "http://0.0.0.0:3100",
|
||||
hostIP: "",
|
||||
wantErr: "取不到目标主机 IP",
|
||||
},
|
||||
}
|
||||
for _, c := range cases {
|
||||
got, err := hostPushEndpoint(c.endpoint, c.hostIP, c.dockerMode)
|
||||
if c.wantErr != "" {
|
||||
if err == nil || !strings.Contains(err.Error(), c.wantErr) {
|
||||
t.Fatalf("%s: 期望报错含 %q,实际 got=%q err=%v", c.name, c.wantErr, got, err)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("%s: 不应报错: %v", c.name, err)
|
||||
}
|
||||
if got != c.want {
|
||||
t.Fatalf("%s: 期望 %q,实际 %q", c.name, c.want, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 渲染出来的 promtail 配置里不能再出现 0.0.0.0 这类推不上去的地址。
|
||||
func TestHostPromtailConfigUsesReachableEndpoint(t *testing.T) {
|
||||
fixed, err := hostPushEndpoint("http://0.0.0.0:3100", "172.19.16.8", true)
|
||||
if err != nil {
|
||||
t.Fatalf("纠正失败: %v", err)
|
||||
}
|
||||
cfg, err := PromtailConfigForHost(HostPromtailOptions{
|
||||
Endpoint: fixed, Cluster: "host", Hostname: "h1", LogPath: "/var/log/a.log",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("渲染失败: %v", err)
|
||||
}
|
||||
if strings.Contains(cfg, "0.0.0.0") {
|
||||
t.Fatalf("配置里仍是通配地址:\n%s", cfg)
|
||||
}
|
||||
if !strings.Contains(cfg, "url: http://172.19.16.8:3100/loki/api/v1/push") {
|
||||
t.Fatalf("推送地址不是纠正后的地址:\n%s", cfg)
|
||||
}
|
||||
}
|
||||
@@ -45,7 +45,9 @@ detect_node() {
|
||||
detect_go() {
|
||||
if command -v go &> /dev/null; then
|
||||
GO_VERSION=$(go version 2>/dev/null | awk '{print $3}')
|
||||
echo "[INFO] Go detected: $GO_VERSION"
|
||||
# INFO 必须走 stderr:detect_go 的 stdout 会被当作命令路径捕获(
|
||||
# 原来把 INFO 打到 stdout,GO_CMD 变成 "[INFO] Go detected: go1.24" → 构建直接失败)
|
||||
echo "[INFO] Go detected: $GO_VERSION" >&2
|
||||
echo "go"
|
||||
elif [ -f "$HOME/go/bin/go" ]; then
|
||||
echo "$HOME/go/bin/go"
|
||||
|
||||
@@ -11,6 +11,15 @@ export interface CollectorPreview {
|
||||
syslogFormat?: string;
|
||||
}
|
||||
|
||||
export interface CollectorLogs {
|
||||
/** 日志来源:docker | systemd | kubernetes */
|
||||
source: string;
|
||||
/** 展示用的来源描述(容器名 / systemd 单元 / Pod) */
|
||||
sourceLabel: string;
|
||||
/** 日志正文(可能为空 = 采集器尚未产生日志) */
|
||||
content: string;
|
||||
}
|
||||
|
||||
export const collectorApi = {
|
||||
list: (params: ListParams = {}, options?: RequestOptions) =>
|
||||
http.get<PageData<Collector>>(`/collectors${buildQuery(params)}`, options),
|
||||
@@ -42,4 +51,8 @@ export const collectorApi = {
|
||||
install: (id: number) => http.post<{ message: string }>(`/collectors/${id}/install`),
|
||||
|
||||
uninstall: (id: number) => http.post<{ message: string }>(`/collectors/${id}/uninstall`),
|
||||
|
||||
/** 读取采集器最近运行日志(主机:docker logs / journalctl;K8s:Pod 日志) */
|
||||
logs: (id: number, tail = 200) =>
|
||||
http.get<CollectorLogs>(`/collectors/${id}/logs?tail=${tail}`),
|
||||
};
|
||||
@@ -25,14 +25,32 @@ const collectors = {
|
||||
namespace: 'Namespace',
|
||||
logPath: 'Log Path',
|
||||
target: 'Target',
|
||||
runMode: 'Run Mode',
|
||||
unknown: 'Unknown',
|
||||
},
|
||||
runMode: {
|
||||
docker: 'Docker container',
|
||||
systemd: 'systemd service',
|
||||
},
|
||||
action: {
|
||||
install: 'Deploy',
|
||||
uninstall: 'Uninstall',
|
||||
logs: 'Logs',
|
||||
editAria: 'Edit collection policy {{name}}',
|
||||
deleteAria: 'Delete collector {{name}}',
|
||||
},
|
||||
logs: {
|
||||
title: 'Collector logs · {{name}}',
|
||||
description: 'Recent collector logs (host: docker logs / journalctl; K8s: collector Pod logs)',
|
||||
source: 'Source: {{source}}',
|
||||
tailLines: '{{count}} lines',
|
||||
refresh: 'Refresh',
|
||||
empty: 'No log output yet (the collector may have just started, or nothing matched so far)',
|
||||
copy: 'Copy',
|
||||
copied: 'Copied',
|
||||
close: 'Close',
|
||||
failed: 'Failed to read collector logs',
|
||||
},
|
||||
toast: {
|
||||
loadOptionsFailed: 'Failed to load Loki deployments, clusters, or hosts',
|
||||
installFailed: 'Failed to deploy collector',
|
||||
|
||||
@@ -26,14 +26,33 @@ const collectors = {
|
||||
namespace: '命名空间',
|
||||
logPath: '日志路径',
|
||||
target: '目标',
|
||||
runMode: '运行方式',
|
||||
unknown: '未知',
|
||||
},
|
||||
// 主机采集器托管方式(安装时自动探测:docker 优先、systemd 兜底)
|
||||
runMode: {
|
||||
docker: 'Docker 容器',
|
||||
systemd: 'systemd 服务',
|
||||
},
|
||||
action: {
|
||||
install: '部署',
|
||||
uninstall: '卸载',
|
||||
logs: '日志',
|
||||
editAria: '编辑采集策略 {{name}}',
|
||||
deleteAria: '删除采集器 {{name}}',
|
||||
},
|
||||
logs: {
|
||||
title: '采集器日志 · {{name}}',
|
||||
description: '采集器最近运行日志(主机:docker logs / journalctl;K8s:采集 Pod 日志)',
|
||||
source: '来源:{{source}}',
|
||||
tailLines: '{{count}} 行',
|
||||
refresh: '刷新',
|
||||
empty: '暂无日志输出(采集器可能刚启动,或暂时没有匹配的日志行)',
|
||||
copy: '复制',
|
||||
copied: '已复制',
|
||||
close: '关闭',
|
||||
failed: '读取采集器日志失败',
|
||||
},
|
||||
toast: {
|
||||
loadOptionsFailed: '加载 Loki 部署/集群/主机数据失败',
|
||||
installFailed: '部署采集器失败',
|
||||
|
||||
@@ -5,6 +5,7 @@ import '@/i18n/locales/zh/collectors'
|
||||
import '@/i18n/locales/en/collectors'
|
||||
import {
|
||||
RadioTower,
|
||||
ScrollText,
|
||||
Trash2,
|
||||
Upload,
|
||||
Download,
|
||||
@@ -36,16 +37,23 @@ interface CollectorCardProps {
|
||||
onUninstall: (id: number) => void
|
||||
onDelete: (c: Collector) => void
|
||||
onEdit: (c: Collector) => void
|
||||
onLogs: (c: Collector) => void
|
||||
}
|
||||
|
||||
/** 采集策略卡片 — 统一 ResourceCard 样式 */
|
||||
export const CollectorCard = memo(function CollectorCard({
|
||||
collector, operating, onInstall, onUninstall, onDelete, onEdit,
|
||||
collector, operating, onInstall, onUninstall, onDelete, onEdit, onLogs,
|
||||
}: CollectorCardProps) {
|
||||
const { t } = useTranslation('collectors')
|
||||
const meta = COLLECTOR_TYPE_META[collector.type] ?? FALLBACK_META
|
||||
const Icon = meta.icon
|
||||
const isBusy = operating === collector.id
|
||||
// 运行方式只对主机采集器有意义(容器 vs systemd 单元);
|
||||
// K8s 类型的运行形态已由类型徽章(DaemonSet / Sidecar)表达,不重复占一行。
|
||||
const runModeLabel =
|
||||
collector.type === 'host-promtail' && collector.runMode
|
||||
? t(`runMode.${collector.runMode}`)
|
||||
: ''
|
||||
return (
|
||||
<ResourceCard
|
||||
icon={Icon}
|
||||
@@ -55,6 +63,7 @@ export const CollectorCard = memo(function CollectorCard({
|
||||
badges={<StatusBadge status={collector.status} />}
|
||||
fields={[
|
||||
{ label: t('field.loki'), value: collector.lokiDeployment?.name || t('field.unknown') },
|
||||
...(runModeLabel ? [{ label: t('field.runMode'), value: runModeLabel }] : []),
|
||||
...(collector.cluster ? [{ label: t('field.cluster'), value: collector.cluster.name }] : []),
|
||||
...(collector.host ? [{ label: t('field.host'), value: `${collector.host.name}(${collector.host.hostIP})` }] : []),
|
||||
...(collector.namespace ? [{ label: t('field.namespace'), value: collector.namespace }] : []),
|
||||
@@ -67,6 +76,7 @@ export const CollectorCard = memo(function CollectorCard({
|
||||
<CardActionButton icon={Pencil} ariaLabel={t('action.editAria', { name: collector.name })} onClick={() => onEdit(collector)} />
|
||||
<CardActionButton icon={Upload} label={isBusy ? t('common:dialog.processing') : t('action.install')} disabled={isBusy} onClick={() => onInstall(collector.id)} />
|
||||
<CardActionButton icon={Download} label={t('action.uninstall')} disabled={isBusy} onClick={() => onUninstall(collector.id)} />
|
||||
<CardActionButton icon={ScrollText} label={t('action.logs')} onClick={() => onLogs(collector)} />
|
||||
<CardActionButton icon={Trash2} ariaLabel={t('action.deleteAria', { name: collector.name })} destructive onClick={() => onDelete(collector)} />
|
||||
</>
|
||||
}
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
import { memo, useCallback, useEffect, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
// collectors 域词典(zh/en 自注册模块,副作用导入须先于 useTranslation 使用)
|
||||
import '@/i18n/locales/zh/collectors'
|
||||
import '@/i18n/locales/en/collectors'
|
||||
import { RefreshCw } from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '@/components/ui/dialog'
|
||||
import { collectorApi, type CollectorLogs } from '@/api'
|
||||
import { LoadingSpinner } from '@/components/LoadingSpinner'
|
||||
import { toast } from 'sonner'
|
||||
import type { Collector } from '@/types'
|
||||
|
||||
interface CollectorLogsDialogProps {
|
||||
collector: Collector | null
|
||||
onOpenChange: (open: boolean) => void
|
||||
}
|
||||
|
||||
/** 可选的日志行数(默认 200:够看启动报错,又不至于把弹窗卡住) */
|
||||
const TAIL_OPTIONS = [200, 500, 1000]
|
||||
|
||||
/**
|
||||
* 采集器运行日志弹窗 —— 主机采集器在 docker 模式下是 `docker logs`,
|
||||
* systemd 模式下是 `journalctl -u promtail`;K8s 采集器是采集 Pod 的容器日志。
|
||||
* 排「采集器起没起来、在报什么错」不用再登机器。
|
||||
*/
|
||||
export const CollectorLogsDialog = memo(function CollectorLogsDialog({
|
||||
collector,
|
||||
onOpenChange,
|
||||
}: CollectorLogsDialogProps) {
|
||||
const { t } = useTranslation('collectors')
|
||||
const [logs, setLogs] = useState<CollectorLogs | null>(null)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
const [tail, setTail] = useState(TAIL_OPTIONS[0])
|
||||
|
||||
const load = useCallback(
|
||||
async (id: number, lines: number) => {
|
||||
setLoading(true)
|
||||
setError('')
|
||||
try {
|
||||
setLogs(await collectorApi.logs(id, lines))
|
||||
} catch (err) {
|
||||
// 后端会指名原因(未部署 / 没有容器也没有 systemd 单元 / 缺 pods/log 权限),直接展示
|
||||
setError(err instanceof Error ? err.message : t('logs.failed'))
|
||||
setLogs(null)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
},
|
||||
[t]
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (!collector) {
|
||||
setLogs(null)
|
||||
setError('')
|
||||
return
|
||||
}
|
||||
setTail(TAIL_OPTIONS[0])
|
||||
void load(collector.id, TAIL_OPTIONS[0])
|
||||
}, [collector, load])
|
||||
|
||||
const copying = Boolean(logs?.content)
|
||||
|
||||
return (
|
||||
<Dialog open={collector !== null} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-3xl max-h-[90vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t('logs.title', { name: collector?.name ?? '' })}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{logs?.sourceLabel ? t('logs.source', { source: logs.sourceLabel }) : t('logs.description')}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{TAIL_OPTIONS.map((n) => (
|
||||
<Button
|
||||
key={n}
|
||||
type="button"
|
||||
size="sm"
|
||||
variant={tail === n ? 'default' : 'outline'}
|
||||
className="h-7 text-xs"
|
||||
disabled={loading || !collector}
|
||||
onClick={() => {
|
||||
setTail(n)
|
||||
if (collector) void load(collector.id, n)
|
||||
}}
|
||||
>
|
||||
{t('logs.tailLines', { count: n })}
|
||||
</Button>
|
||||
))}
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="h-7 text-xs ml-auto"
|
||||
disabled={loading || !collector}
|
||||
onClick={() => {
|
||||
if (collector) void load(collector.id, tail)
|
||||
}}
|
||||
>
|
||||
<RefreshCw className="w-3.5 h-3.5 mr-1" />
|
||||
{t('logs.refresh')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<LoadingSpinner className="h-40" />
|
||||
) : error ? (
|
||||
<div className="rounded-md border border-destructive/40 bg-destructive/5 p-3 text-xs text-destructive whitespace-pre-wrap">
|
||||
{error}
|
||||
</div>
|
||||
) : (
|
||||
<pre className="max-h-[55vh] overflow-auto rounded-md bg-muted p-3 text-[11px] leading-relaxed whitespace-pre-wrap break-all">
|
||||
{logs?.content || t('logs.empty')}
|
||||
</pre>
|
||||
)}
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={!copying}
|
||||
onClick={() => {
|
||||
if (!logs?.content) return
|
||||
void navigator.clipboard?.writeText(logs.content)
|
||||
toast.success(t('logs.copied'))
|
||||
}}
|
||||
>
|
||||
{t('logs.copy')}
|
||||
</Button>
|
||||
<Button type="button" size="sm" onClick={() => onOpenChange(false)}>
|
||||
{t('logs.close')}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
})
|
||||
@@ -18,6 +18,7 @@ import type { Cluster, Collector, Host, LokiDeployment } from '@/types'
|
||||
import { CollectorCard } from './components/CollectorCard'
|
||||
import { CollectorFormDialog } from './components/CollectorFormDialog'
|
||||
import { CollectorEditDialog } from './components/CollectorEditDialog'
|
||||
import { CollectorLogsDialog } from './components/CollectorLogsDialog'
|
||||
|
||||
/**
|
||||
* 日志采集页 — 采集策略编排:列表走 useListQuery(取消+版本号),
|
||||
@@ -38,6 +39,8 @@ const CollectorsPage = memo(function CollectorsPage() {
|
||||
const [formOpen, setFormOpen] = useState(false)
|
||||
const [editTarget, setEditTarget] = useState<Collector | null>(null)
|
||||
const [deleteTarget, setDeleteTarget] = useState<Collector | null>(null)
|
||||
/** 日志弹窗目标(null = 关闭) */
|
||||
const [logsTarget, setLogsTarget] = useState<Collector | null>(null)
|
||||
const [deleting, setDeleting] = useState(false)
|
||||
/** 部署/卸载指令进行中的采集器 ID(按钮 loading + 防重复点击) */
|
||||
const [operating, setOperating] = useState<number | null>(null)
|
||||
@@ -146,6 +149,7 @@ const CollectorsPage = memo(function CollectorsPage() {
|
||||
onUninstall={(id) => handleAction(id, 'uninstall')}
|
||||
onDelete={setDeleteTarget}
|
||||
onEdit={setEditTarget}
|
||||
onLogs={setLogsTarget}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
@@ -169,6 +173,8 @@ const CollectorsPage = memo(function CollectorsPage() {
|
||||
|
||||
<CollectorEditDialog collector={editTarget} onOpenChange={(open) => { if (!open) setEditTarget(null) }} onSaved={query.refresh} />
|
||||
|
||||
<CollectorLogsDialog collector={logsTarget} onOpenChange={(open) => { if (!open) setLogsTarget(null) }} />
|
||||
|
||||
<ConfirmDialog
|
||||
open={deleteTarget !== null}
|
||||
onOpenChange={(open) => {
|
||||
|
||||
@@ -225,6 +225,8 @@ export interface Collector {
|
||||
resolvedHostname?: string;
|
||||
/** 上次安装探测到的 syslog 时间戳格式:iso8601 | rfc3164 */
|
||||
syslogFormat?: string;
|
||||
/** 主机采集器的托管方式(安装时自动探测决定):docker | systemd */
|
||||
runMode?: 'docker' | 'systemd' | '';
|
||||
status: string;
|
||||
statusMessage: string;
|
||||
createdAt: string;
|
||||
|
||||
Reference in new issue
Block a user