=== 设计边界(刻意不做"编辑完整 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
191 lines
5.1 KiB
Go
191 lines
5.1 KiB
Go
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)
|
||
}
|