=== 设计边界(刻意不做"编辑完整 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
165 lines
5.6 KiB
Go
165 lines
5.6 KiB
Go
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 = ×tampYAML{
|
||
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, "; ")
|
||
}
|