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

+8
View File
@@ -55,4 +55,12 @@ export const collectorApi = {
/** 读取采集器最近运行日志(主机:docker logs / journalctl;K8s:Pod 日志) */
logs: (id: number, tail = 200) =>
http.get<CollectorLogs>(`/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 }),
};
@@ -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',
@@ -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: '添加采集项',
@@ -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')}
</Button>
</div>
<ScrapeJobsEditor drafts={jobs} onChange={setJobs} />
<div className="flex items-center gap-2">
<div className="inline-flex rounded-md border p-0.5">
<button type="button" onClick={() => void switchJobsMode('form')} disabled={jobsYamlBusy}
className={`rounded px-2.5 py-1 text-xs ${jobsMode === 'form' ? 'bg-primary text-primary-foreground' : 'text-muted-foreground'}`}>
{t('edit.jobs.modeForm')}
</button>
<button type="button" onClick={() => void switchJobsMode('yaml')} disabled={jobsYamlBusy}
className={`rounded px-2.5 py-1 text-xs ${jobsMode === 'yaml' ? 'bg-primary text-primary-foreground' : 'text-muted-foreground'}`}>
{t('edit.jobs.modeYaml')}
</button>
</div>
{jobsYamlBusy && <span className="text-xs text-muted-foreground">{t('edit.jobs.converting')}</span>}
</div>
{jobsMode === 'form' ? (
<ScrapeJobsEditor drafts={jobs} onChange={setJobs} />
) : (
<div className="space-y-1">
<textarea
value={jobsYaml}
onChange={e => { setJobsYaml(e.target.value); setJobsYamlError('') }}
spellCheck={false}
className="h-72 w-full resize-y rounded-md border bg-muted/30 p-2 font-mono text-xs leading-relaxed outline-none focus-visible:ring-1 focus-visible:ring-ring"
/>
<p className="text-xs text-muted-foreground">{t('edit.jobs.yamlHint')}</p>
{jobsYamlError && <p className="text-xs text-destructive">{jobsYamlError}</p>}
</div>
)}
<p className="text-xs text-muted-foreground">{t('edit.jobs.hint')}</p>
{collector?.resolvedHostname && (
<p className="text-xs text-muted-foreground">