Files
opsx 3b297d0429
CI / Backend (go vet + test) (push) Canceled after 0s
CI / Frontend (lint + build + test) (push) Canceled after 0s
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
2026-10-30 16:10:51 +00:00

191 lines
5.1 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package controllers
import (
"strconv"
"lokistack/dto"
"lokistack/models"
"lokistack/services"
"lokistack/utils"
"github.com/gin-gonic/gin"
)
type CollectorController struct {
service *services.CollectorService
}
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) {
return
}
collector, err := ctrl.service.Create(&services.CreateCollectorRequest{
Name: req.Name, Type: req.Type, LokiDeploymentID: req.LokiDeploymentID,
HostID: req.HostID, ClusterID: req.ClusterID, TargetDeployment: req.TargetDeployment,
Namespace: req.Namespace, LogPath: req.LogPath, Labels: req.Labels,
ScrapeJobs: req.ScrapeJobs, TargetNamespaces: req.TargetNamespaces,
})
if err != nil {
utils.Error(c, 400, err.Error())
return
}
utils.Success(c, models.SanitizedJSON(collector))
}
func (ctrl *CollectorController) Get(c *gin.Context) {
id, err := utils.IntParam(c, "id")
if err != nil {
utils.BadRequest(c, "ID 无效")
return
}
collector, err := ctrl.service.Get(uint(id))
if err != nil {
utils.NotFound(c, "采集器不存在")
return
}
utils.Success(c, models.SanitizedJSON(collector))
}
func (ctrl *CollectorController) List(c *gin.Context) {
page, pageSize := utils.Pagination(c)
collectors, total, err := ctrl.service.List(page, pageSize)
if err != nil {
utils.Error(c, 500, err.Error())
return
}
utils.Success(c, utils.PageData{
List: models.SanitizedJSON(collectors), Total: total, Page: page, PageSize: pageSize,
})
}
func (ctrl *CollectorController) Update(c *gin.Context) {
id, err := utils.IntParam(c, "id")
if err != nil {
utils.BadRequest(c, "ID 无效")
return
}
var updates map[string]interface{}
if !utils.BindAndValidate(c, &updates) {
return
}
if err := ctrl.service.Update(uint(id), updates); err != nil {
utils.Error(c, 400, err.Error())
return
}
utils.Success(c, gin.H{"message": "更新成功"})
}
func (ctrl *CollectorController) Delete(c *gin.Context) {
id, err := utils.IntParam(c, "id")
if err != nil {
utils.BadRequest(c, "ID 无效")
return
}
if err := ctrl.service.Delete(uint(id)); err != nil {
utils.Error(c, 400, err.Error())
return
}
utils.Success(c, gin.H{"message": "删除成功"})
}
func (ctrl *CollectorController) Install(c *gin.Context) {
id, err := utils.IntParam(c, "id")
if err != nil {
utils.BadRequest(c, "ID 无效")
return
}
go ctrl.service.Install(uint(id))
utils.Success(c, gin.H{"message": "已开始部署采集器"})
}
// Preview 渲染「当前配置 + 本次改动」的 promtail.yaml,只预览不落库。
// 供结构化编辑器里的「预览 YAML」按钮使用:用户能先看到将要写到目标机的配置,
// 保存前就发现路径/正则写错,而不是点了部署才发现采集起不来。
func (ctrl *CollectorController) Preview(c *gin.Context) {
id, err := utils.IntParam(c, "id")
if err != nil {
utils.BadRequest(c, "ID 无效")
return
}
var updates map[string]interface{}
if !utils.BindAndValidate(c, &updates) {
return
}
out, err := ctrl.service.PreviewConfig(uint(id), updates)
if err != nil {
utils.Error(c, 400, err.Error())
return
}
utils.Success(c, out)
}
func (ctrl *CollectorController) Uninstall(c *gin.Context) {
id, err := utils.IntParam(c, "id")
if err != nil {
utils.BadRequest(c, "ID 无效")
return
}
go ctrl.service.Uninstall(uint(id))
utils.Success(c, gin.H{"message": "已开始卸载采集器"})
}
// Logs 返回采集器最近的运行日志(主机:docker logs / journalctl;K8s:Pod 日志)。
// 主机模式下采集器日志原来只能登机器看 docker logs / journalctl,容器化后这是
// 顺带拿到的能力,排「采集器起没起来、在报什么错」不用再 SSH。
func (ctrl *CollectorController) Logs(c *gin.Context) {
id, err := utils.IntParam(c, "id")
if err != nil {
utils.BadRequest(c, "ID 无效")
return
}
tail := 200
if v := c.Query("tail"); v != "" {
if n, convErr := strconv.Atoi(v); convErr == nil && n > 0 {
tail = n
}
}
out, err := ctrl.service.CollectorLogs(uint(id), tail)
if err != nil {
utils.Error(c, 400, err.Error())
return
}
utils.Success(c, out)
}