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
This commit is contained in:
1 parent
c8d026adc7
commit
3b297d0429
8 files changed
+396
-2
No files matched your search
@@ -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">
|
||||
|
||||
Reference in new issue
Block a user