From 3b297d0429f8fb8b7c5571eb87bceaa5093fdb94 Mon Sep 17 00:00:00 2001 From: opsx Date: Fri, 30 Oct 2026 16:10:51 +0000 Subject: [PATCH] =?UTF-8?q?feat(collectors):=20=E9=87=87=E9=9B=86=E9=A1=B9?= =?UTF-8?q?=E6=94=AF=E6=8C=81=E3=80=8C=E8=A1=A8=E5=8D=95=20/=20YAML?= =?UTF-8?q?=E3=80=8D=E5=8F=8C=E6=A8=A1=E5=BC=8F=E7=BC=96=E8=BE=91=EF=BC=88?= =?UTF-8?q?=E6=96=B9=E6=A1=88=20A=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit === 设计边界(刻意不做"编辑完整 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 --- backend/controllers/collector.go | 32 ++++ backend/routers/router.go | 3 + backend/services/collector_jobs_yaml.go | 164 ++++++++++++++++++ backend/services/collector_jobs_yaml_test.go | 111 ++++++++++++ frontend/src/api/collector/index.ts | 8 + frontend/src/i18n/locales/en/collectors.ts | 4 + frontend/src/i18n/locales/zh/collectors.ts | 4 + .../components/CollectorEditDialog.tsx | 72 +++++++- 8 files changed, 396 insertions(+), 2 deletions(-) create mode 100644 backend/services/collector_jobs_yaml.go create mode 100644 backend/services/collector_jobs_yaml_test.go diff --git a/backend/controllers/collector.go b/backend/controllers/collector.go index cd54745..48812a3 100644 --- a/backend/controllers/collector.go +++ b/backend/controllers/collector.go @@ -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) { diff --git a/backend/routers/router.go b/backend/routers/router.go index 1e89f2c..29b2bfb 100644 --- a/backend/routers/router.go +++ b/backend/routers/router.go @@ -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 存在与否注册,目录不存在时路由不生效 diff --git a/backend/services/collector_jobs_yaml.go b/backend/services/collector_jobs_yaml.go new file mode 100644 index 0000000..aabaf6d --- /dev/null +++ b/backend/services/collector_jobs_yaml.go @@ -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 = ×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, "; ") +} diff --git a/backend/services/collector_jobs_yaml_test.go b/backend/services/collector_jobs_yaml_test.go new file mode 100644 index 0000000..6d7af4a --- /dev/null +++ b/backend/services/collector_jobs_yaml_test.go @@ -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) + } + }) + } +} diff --git a/frontend/src/api/collector/index.ts b/frontend/src/api/collector/index.ts index 3dac531..60ce571 100644 --- a/frontend/src/api/collector/index.ts +++ b/frontend/src/api/collector/index.ts @@ -55,4 +55,12 @@ export const collectorApi = { /** 读取采集器最近运行日志(主机:docker logs / journalctl;K8s:Pod 日志) */ logs: (id: number, tail = 200) => http.get(`/collectors/${id}/logs?tail=${tail}`), + + /** 采集项 → YAML(切到 YAML 编辑模式) */ + renderJobs: (jobs: ScrapeJob[]) => + http.post<{ yaml: string }>('/collector-jobs/render', { jobs }), + + /** YAML → 采集项(切回表单 / 保存前校验) */ + parseJobs: (yaml: string) => + http.post<{ jobs: ScrapeJob[] }>('/collector-jobs/parse', { yaml }), }; diff --git a/frontend/src/i18n/locales/en/collectors.ts b/frontend/src/i18n/locales/en/collectors.ts index 7fa9c6c..dc377b2 100644 --- a/frontend/src/i18n/locales/en/collectors.ts +++ b/frontend/src/i18n/locales/en/collectors.ts @@ -146,6 +146,10 @@ const collectors = { logPathHint: 'Sidecar needs a shared log volume or the dedicated logs directory; click Deploy after saving.', // ── host mode: structured scrape jobs ── jobs: { + modeForm: 'Form', + modeYaml: 'YAML', + converting: 'Converting…', + yamlHint: 'Describes collection rules only (paths / multiline / JSON / drop / rate limit). clients, positions and server are generated by the platform — editing them here would break the collector.', title: 'Log Scrape Jobs', hint: 'One entry corresponds to one promtail job; add one path per directory. Click Deploy after saving.', add: 'Add job', diff --git a/frontend/src/i18n/locales/zh/collectors.ts b/frontend/src/i18n/locales/zh/collectors.ts index 672f207..2a643c2 100644 --- a/frontend/src/i18n/locales/zh/collectors.ts +++ b/frontend/src/i18n/locales/zh/collectors.ts @@ -151,6 +151,10 @@ const collectors = { logPathHint: 'Sidecar 需共享日志卷或专用 logs 目录;保存后点击「部署」生效。', // ── 主机模式:结构化采集项(B 档) ── jobs: { + modeForm: '表单', + modeYaml: 'YAML', + converting: '转换中…', + yamlHint: '只描述采集规则(路径/多行/JSON/丢弃/限流)。clients、positions、server 由平台生成,不在此修改——写坏它们会让采集器整体失效。', title: '日志采集项', hint: '每个采集项对应一个 promtail job;每个目录填写一个路径。保存后点击「部署」下发。', add: '添加采集项', diff --git a/frontend/src/pages/collectors/components/CollectorEditDialog.tsx b/frontend/src/pages/collectors/components/CollectorEditDialog.tsx index 3b2a4a4..89f7a80 100644 --- a/frontend/src/pages/collectors/components/CollectorEditDialog.tsx +++ b/frontend/src/pages/collectors/components/CollectorEditDialog.tsx @@ -1,4 +1,4 @@ -import { memo, useEffect, useMemo, useState } from 'react' +import { memo, useCallback, useEffect, useMemo, useState } from 'react' import { useForm } from 'react-hook-form' import { zodResolver } from '@hookform/resolvers/zod' import { z } from 'zod' @@ -73,6 +73,35 @@ export const CollectorEditDialog = memo(function CollectorEditDialog({ collector const supportsPath = !isDaemonSet && !isHost const selectedNs = form.watch('targetNamespaces') + // ── 采集项 YAML 双模式 ─────────────────────────────────────────── + // 表单与 YAML 是同一个数据的两个视图;切换时做一次转换, + // 保存时若停留在 YAML 模式,先解析成结构化采集项再提交(校验与表单一致)。 + const [jobsMode, setJobsMode] = useState<'form' | 'yaml'>('form') + const [jobsYaml, setJobsYaml] = useState('') + const [jobsYamlError, setJobsYamlError] = useState('') + const [jobsYamlBusy, setJobsYamlBusy] = useState(false) + + const switchJobsMode = useCallback(async (next: 'form' | 'yaml') => { + if (next === jobsMode) return + setJobsYamlError('') + setJobsYamlBusy(true) + try { + if (next === 'yaml') { + const res = await collectorApi.renderJobs(jobPayload()) + setJobsYaml(res.yaml ?? '') + } else { + const res = await collectorApi.parseJobs(jobsYaml) + setJobs(draftsFromCollector(res.jobs ?? [], form.getValues('logPath'))) + } + setJobsMode(next) + } catch (err) { + // 转不回表单就留在 YAML 模式并说明原因,绝不能悄悄丢掉用户改动 + setJobsYamlError(err instanceof Error ? err.message : String(err)) + } finally { + setJobsYamlBusy(false) + } + }, [jobsMode, jobsYaml, jobs]) + useEffect(() => { if (!collector) return form.reset({ @@ -146,6 +175,19 @@ export const CollectorEditDialog = memo(function CollectorEditDialog({ collector const submit = form.handleSubmit(async (values) => { if (!collector) return if (isHost) { + // 停留在 YAML 模式时,先把 YAML 解析成结构化采集项再走原有校验。 + // 解析失败直接中止保存(避免把非法配置提交上去才发现采集器起不来)。 + if (jobsMode === 'yaml') { + try { + const parsed = await collectorApi.parseJobs(jobsYaml) + setJobs(draftsFromCollector(parsed.jobs ?? [], form.getValues('logPath'))) + } catch (err) { + const message = err instanceof Error ? err.message : String(err) + setJobsYamlError(message) + toast.error(message) + return + } + } const issues = draftIssues(jobs) if (issues.length > 0) { toast.error(issues[0]) @@ -195,7 +237,33 @@ export const CollectorEditDialog = memo(function CollectorEditDialog({ collector {previewing ? t('edit.preview.loading') : t('edit.preview.button')} - +
+
+ + +
+ {jobsYamlBusy && {t('edit.jobs.converting')}} +
+ {jobsMode === 'form' ? ( + + ) : ( +
+