feat(collectors): 采集项支持「表单 / YAML」双模式编辑(方案 A)
CI / Backend (go vet + test) (push) Canceled after 0s
CI / Frontend (lint + build + test) (push) Canceled after 0s

=== 设计边界(刻意不做"编辑完整 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
This commit is contained in:
opsx committed 2026-10-30 16:10:51 +00:00
1 parent c8d026adc7
commit 3b297d0429
8 files changed
+396 -2

No files matched your search

+32
View File
@@ -19,6 +19,38 @@ func NewCollectorController() *CollectorController {
return &CollectorController{service: services.NewCollectorService()}
}
// RenderJobsYAML 把结构化采集项渲染成可编辑 YAML(前端切到 YAML 模式时调用)。
func (ctrl *CollectorController) RenderJobsYAML(c *gin.Context) {
var req struct {
Jobs []models.ScrapeJob `json:"jobs"`
}
if !utils.BindAndValidate(c, &req) {
return
}
text, err := services.RenderJobsYAML(req.Jobs)
if err != nil {
utils.Error(c, 400, err.Error())
return
}
utils.Success(c, gin.H{"yaml": text})
}
// ParseJobsYAML 把用户编辑的 YAML 解析回结构化采集项(切回表单 / 保存前调用)。
func (ctrl *CollectorController) ParseJobsYAML(c *gin.Context) {
var req struct {
YAML string `json:"yaml"`
}
if !utils.BindAndValidate(c, &req) {
return
}
jobs, err := services.ParseJobsYAML(req.YAML)
if err != nil {
utils.Error(c, 400, err.Error())
return
}
utils.Success(c, gin.H{"jobs": jobs})
}
func (ctrl *CollectorController) Create(c *gin.Context) {
var req dto.CreateCollectorRequest
if !utils.BindAndValidate(c, &req) {
+3
View File
@@ -231,6 +231,9 @@ func SetupRouter() *gin.Engine {
authorized.POST("/collectors/:id/uninstall", collectorCtrl.Uninstall)
authorized.POST("/collectors/:id/preview", collectorCtrl.Preview)
authorized.GET("/collectors/:id/logs", collectorCtrl.Logs)
// 采集项 YAML 双向转换(方案 A:只开放采集规则,不开放 clients/positions)
authorized.POST("/collector-jobs/render", collectorCtrl.RenderJobsYAML)
authorized.POST("/collector-jobs/parse", collectorCtrl.ParseJobsYAML)
// 文档站(docsify,docs/)与落地页(homepage/)随二进制一起发布;
// 独立于前端 dist 存在与否注册,目录不存在时路由不生效
+164
View File
@@ -0,0 +1,164 @@
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, "; ")
}
@@ -0,0 +1,111 @@
package services
import (
"strings"
"testing"
lokimodels "lokistack/models"
)
// YAML 双模式的核心保证:**表单 → YAML → 表单 必须无损往返**。
// 如果往返丢字段(比如 multiline.maxLines 或 rateLimit),用户切一下模式
// 配置就被悄悄改掉了,这是最难排查的一类问题。
func TestRenderParseJobsYAMLRoundTrip(t *testing.T) {
original := []lokimodels.ScrapeJob{
{
Name: "app",
Paths: []string{"/var/log/app/*.log", "/data/logs/*.log"},
Labels: map[string]string{
"tier": "backend",
},
Encoding: "utf-8",
JSON: true,
Multiline: &lokimodels.JobMultiline{
FirstLine: `^\d{4}-\d{2}-\d{2}`,
MaxLines: 256,
},
DropIfMatch: []string{`/healthz`, `/metrics`},
RateLimit: &lokimodels.JobRateLimit{Rate: 2000, Burst: 4000},
Timestamp: &lokimodels.JobTimestamp{
Source: "ts",
Format: "RFC3339Nano",
Location: "Local",
},
},
{
Name: "system",
Paths: []string{"/var/log/messages"},
System: true,
},
}
text, err := RenderJobsYAML(original)
if err != nil {
t.Fatalf("渲染失败: %v", err)
}
// 头部注释必须说明"这不是完整 promtail 配置",避免用户以为能改 clients
if !strings.Contains(text, "clients") || !strings.Contains(text, "不在此处修改") {
t.Error("渲染结果缺少边界说明注释")
}
parsed, err := ParseJobsYAML(text)
if err != nil {
t.Fatalf("解析失败: %v\n--- YAML ---\n%s", err, text)
}
if len(parsed) != len(original) {
t.Fatalf("采集项数量不符: got %d want %d", len(parsed), len(original))
}
first := parsed[0]
if first.Name != "app" || len(first.Paths) != 2 {
t.Errorf("基础字段丢失: %+v", first)
}
if first.Multiline == nil || first.Multiline.MaxLines != 256 {
t.Errorf("multiline 丢失: %+v", first.Multiline)
}
if first.RateLimit == nil || first.RateLimit.Rate != 2000 || first.RateLimit.Burst != 4000 {
t.Errorf("rateLimit 丢失: %+v", first.RateLimit)
}
if len(first.DropIfMatch) != 2 {
t.Errorf("dropIfMatch 丢失: %+v", first.DropIfMatch)
}
if !first.JSON || first.Encoding != "utf-8" {
t.Errorf("json/encoding 丢失: json=%v enc=%q", first.JSON, first.Encoding)
}
if first.Timestamp == nil || first.Timestamp.Source != "ts" || first.Timestamp.Location != "Local" {
t.Errorf("timestamp 丢失: %+v", first.Timestamp)
}
if first.Labels["tier"] != "backend" {
t.Errorf("labels 丢失: %+v", first.Labels)
}
if !parsed[1].System {
t.Error("system 标记丢失")
}
}
// 报错必须具体:语法错要能定位,语义错要指出是哪个采集项,不能笼统"格式错误"。
func TestParseJobsYAMLErrors(t *testing.T) {
cases := []struct {
name string
in string
want string
}{
{"空内容", " ", "YAML 为空"},
{"语法错误", "- name: app\n paths: [/a\n", "YAML 语法错误"},
{"空列表", "[]", "没有采集项"},
{"缺名称", "- paths: [/var/log/x.log]", "缺少名称"},
{"缺路径", "- name: app", "至少需要填写一个日志路径"},
{"名称非法", "- name: 'bad name!'\n paths: [/a]", "不合法"},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
_, err := ParseJobsYAML(c.in)
if err == nil {
t.Fatalf("期望报错,实际通过")
}
if !strings.Contains(err.Error(), c.want) {
t.Errorf("错误信息不够具体: got %q, want contains %q", err.Error(), c.want)
}
})
}
}