Files
LokiStack/backend/services/collector_jobs_yaml.go
opsx 3b297d0429
CI / Backend (go vet + test) (push) Canceled after 0s
CI / Frontend (lint + build + test) (push) Canceled after 0s
feat(collectors): 采集项支持「表单 / YAML」双模式编辑(方案 A)
=== 设计边界(刻意不做"编辑完整 promtail.yaml")===
开放的是**采集项本身**的 YAML 表示(字段与 models.ScrapeJob 一一对应):
  name / paths / labels / encoding / json / system
  multiline(firstline,maxLines) / timestamp / dropIfMatch / rateLimit
clients、positions、server、scrape_configs 外层仍由平台生成。

理由(真实故障面):
  用户删掉 positions → 重启后从头重采,大量重复 + Loki 以
  entry too far behind 拒绝整批;删掉 clients → 完全不外发。
  这两类问题在页面上看不出来,只能登机器看日志,排障成本极高。
  只开放采集规则,写坏了最多某条日志没采到,不会让采集器整体失效。

=== 后端 ===
新增 services/collector_jobs_yaml.go:
  RenderJobsYAML(jobs) → 带边界说明注释的 YAML(明确写"clients/positions 不在此修改")
  ParseJobsYAML(text)  → 复用 models.ValidateScrapeJobs,与表单完全同一套校验
错误信息必须具体:
  YAML 语法错 → 「YAML 语法错误:yaml: line 1: did not find expected ',' or ']'」
  语义错     → 「采集项「app」至少需要填写一个日志路径」
新增路由(用 /collector-jobs/* 前缀,避开与 /collectors/:id 的 gin 通配冲突):
  POST /api/v1/collector-jobs/render
  POST /api/v1/collector-jobs/parse

=== 前端 ===
编辑弹框(主机模式)加「表单 / YAML」切换:
  - 切到 YAML:调 render 把当前采集项渲染出来
  - 切回表单:调 parse 回填;解析失败**留在 YAML 模式**并显示原因
    (绝不能悄悄丢掉用户改动)
  - 停留在 YAML 模式点保存:先 parse,失败则中止保存
  - 编辑区 min-h 固定、等宽字体、可拖拽高度

=== 测试 ===
TestRenderParseJobsYAMLRoundTrip:
  表单→YAML→表单 无损往返,逐字段核对
  (multiline.maxLines / rateLimit / dropIfMatch / timestamp / labels / system)
TestParseJobsYAMLErrors:空内容、语法错、空列表、缺名称、缺路径、名称非法
  → 6 种输入都给出具体错误,不是笼统"格式错误"

=== 实测(c11d2badce64)===
  render: code=0,17 行,含边界说明
  parse : code=0,1 个采集项;往返校验 name=app paths=1 multiline=True
          rateLimit={rate:2000,burst:4000} drop=1 json=True
  错误: 「采集项「app」至少需要填写一个日志路径」
        「YAML 语法错误:yaml: line 1: did not find expected ',' or ']'」
  Pod = Hub latest = c11d2badce64
2026-10-30 16:10:51 +00:00

165 lines
5.6 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package services
import (
"fmt"
"strings"
"gopkg.in/yaml.v3"
lokimodels "lokistack/models"
)
// 「采集 job 片段的 YAML 编辑」支持(方案 A)。
//
// 设计边界(很重要):
// 这里开放的是**采集项本身**的 YAML 表示,字段与 models.ScrapeJob 一一对应,
// **不是**原始 promtail 配置。clients / positions / server / scrape_configs 外层
// 仍由平台生成。
//
// 为什么不做"编辑完整 promtail.yaml":
// 用户一旦删掉 positions,重启后会从头重采(大量重复 + entry too far behind 被
// Loki 拒绝);删掉 clients 则完全不外发。这两类故障在页面上看不出来,
// 只能登机器看日志,排障成本极高。所以只开放"采集规则"这一层,
// 它写坏了最多是某条日志没采到,不会让采集器整体失效。
// jobsYAML 是 ScrapeJob 的 YAML 投影(小写键,便于手写)。
type jobsYAML struct {
Name string `yaml:"name"`
Paths []string `yaml:"paths"`
Labels map[string]string `yaml:"labels,omitempty"`
Encoding string `yaml:"encoding,omitempty"`
System bool `yaml:"system,omitempty"`
JSON bool `yaml:"json,omitempty"`
Multiline *multilineYAML `yaml:"multiline,omitempty"`
Timestamp *timestampYAML `yaml:"timestamp,omitempty"`
DropIfMatch []string `yaml:"dropIfMatch,omitempty"`
RateLimit *rateLimitYAML `yaml:"rateLimit,omitempty"`
}
type multilineYAML struct {
FirstLine string `yaml:"firstline"`
MaxLines int `yaml:"maxLines,omitempty"`
}
type timestampYAML struct {
Source string `yaml:"source"`
Format string `yaml:"format"`
Location string `yaml:"location,omitempty"`
}
type rateLimitYAML struct {
Rate int `yaml:"rate,omitempty"`
Burst int `yaml:"burst,omitempty"`
}
// RenderJobsYAML 把采集项渲染成可编辑 YAML(带头部注释,说明这是什么)。
func RenderJobsYAML(jobs []lokimodels.ScrapeJob) (string, error) {
out := make([]jobsYAML, 0, len(jobs))
for _, job := range jobs {
item := jobsYAML{
Name: job.Name,
Paths: job.Paths,
Labels: job.Labels,
Encoding: job.Encoding,
System: job.System,
JSON: job.JSON,
DropIfMatch: job.DropIfMatch,
}
if job.Multiline != nil && strings.TrimSpace(job.Multiline.FirstLine) != "" {
item.Multiline = &multilineYAML{FirstLine: job.Multiline.FirstLine, MaxLines: job.Multiline.MaxLines}
}
if job.Timestamp != nil && strings.TrimSpace(job.Timestamp.Source) != "" {
item.Timestamp = &timestampYAML{
Source: job.Timestamp.Source, Format: job.Timestamp.Format, Location: job.Timestamp.Location,
}
}
if job.RateLimit != nil && (job.RateLimit.Rate > 0 || job.RateLimit.Burst > 0) {
item.RateLimit = &rateLimitYAML{Rate: job.RateLimit.Rate, Burst: job.RateLimit.Burst}
}
out = append(out, item)
}
body, err := yaml.Marshal(out)
if err != nil {
return "", fmt.Errorf("渲染采集项 YAML 失败: %w", err)
}
var b strings.Builder
b.WriteString("# 采集项(scrape_configs)编辑区 —— 只描述「采哪些文件、怎么解析、怎么过滤」\n")
b.WriteString("# clients / positions / server 等由平台自动生成,不在此处修改\n")
b.WriteString("# 字段:name(必填) paths(必填) labels encoding json system multiline timestamp dropIfMatch rateLimit\n")
b.WriteString(string(body))
return b.String(), nil
}
// ParseJobsYAML 把用户编辑的 YAML 解析回采集项,并做与表单完全相同的校验。
//
// 报错要具体:YAML 语法错给行号,语义错给"第几个采集项/哪个字段",
// 不要笼统地说"格式错误"——否则用户只能靠猜。
func ParseJobsYAML(text string) ([]lokimodels.ScrapeJob, error) {
trimmed := strings.TrimSpace(text)
if trimmed == "" {
return nil, fmt.Errorf("YAML 为空:至少需要一个采集项")
}
var raw []jobsYAML
if err := yaml.Unmarshal([]byte(trimmed), &raw); err != nil {
return nil, fmt.Errorf("YAML 语法错误:%s", prettifyYAMLError(err))
}
if len(raw) == 0 {
return nil, fmt.Errorf("YAML 里没有采集项(应为一个以 - 开头的列表)")
}
jobs := make([]lokimodels.ScrapeJob, 0, len(raw))
for _, item := range raw {
job := lokimodels.ScrapeJob{
Name: strings.TrimSpace(item.Name),
Paths: trimAll(item.Paths),
Labels: item.Labels,
Encoding: strings.TrimSpace(item.Encoding),
System: item.System,
JSON: item.JSON,
DropIfMatch: trimAll(item.DropIfMatch),
}
if item.Multiline != nil {
job.Multiline = &lokimodels.JobMultiline{
FirstLine: item.Multiline.FirstLine, MaxLines: item.Multiline.MaxLines,
}
}
if item.Timestamp != nil {
job.Timestamp = &lokimodels.JobTimestamp{
Source: item.Timestamp.Source, Format: item.Timestamp.Format, Location: item.Timestamp.Location,
}
}
if item.RateLimit != nil {
job.RateLimit = &lokimodels.JobRateLimit{Rate: item.RateLimit.Rate, Burst: item.RateLimit.Burst}
}
jobs = append(jobs, job)
}
if err := lokimodels.ValidateScrapeJobs(jobs); err != nil {
return nil, err
}
return jobs, nil
}
func trimAll(values []string) []string {
out := make([]string, 0, len(values))
for _, v := range values {
if s := strings.TrimSpace(v); s != "" {
out = append(out, s)
}
}
return out
}
// prettifyYAMLError 把 yaml.v3 的多行错误压成一行,保留行号信息。
func prettifyYAMLError(err error) string {
var lines []string
for _, line := range strings.Split(err.Error(), "\n") {
if s := strings.TrimSpace(line); s != "" {
lines = append(lines, s)
}
}
return strings.Join(lines, "; ")
}