Files
LokiStack/backend/services/collector.go
T
opsx 41633f0b38 feat(collector): 采集命名空间多选 + app 标签来源可自定义 + app 白名单
=== 一、采集命名空间改为多选(原来只有文本输入)===
- CollectorEditDialog 重写:勾选式多选面板,从
  GET /clusters/:id/namespaces 拉取真实命名空间列表
- "所有命名空间(全集群采集)"一键全选(targetNamespaces 置空即全集群)
- 支持搜索过滤命名空间;已选数量实时显示
- 保存时为空数组 → Promtail 不加 namespaces 白名单

=== 二、app 标签取值来源可配置 ===
新增 Collector.AppLabelSource:
  留空(默认)= app.kubernetes.io/name → app → controller 名(去hash) → pod 名 四级回退
  可填任意 Pod 标签,如 app.kubernetes.io/name / app / k8s.kuboard.cn/name
  也可直接写 __meta_kubernetes_pod_label_xxx
  → 作为最高优先级插到回退链最前,其余仍兜底

模板侧新增:
  sanitizePromLabel()      标签名 → Prometheus meta label(. / 等转 _)
  promtailAppSourceLabel() 解析用户配置
  promtailAppIncludeFilter() 生成白名单 keep 规则

=== 三、app 白名单(控制采集量)===
新增 Collector.AppInclude []string:
  填了之后生成
    - source_labels: [app]
      regex: ^(app1|app2)$
      action: keep
  只保留匹配的日志流 —— 这是收敛日志量最直接的手段

service.Update 同步支持 appLabelSource / appInclude(兼容 []string 与逗号分隔字符串)。

前端 CollectorEditDialog:
  采集命名空间 = 勾选多选 + 全选
  app 标签取值来源 = 输入框 + 5 个常用值 datalist 提示
  只采集这些 app = 逗号分隔输入

镜像:lghost/lokistack:latest + v0.1 = sha256:43369d06cb28
2026-10-27 03:55:13 +00:00

353 lines
9.5 KiB
Go
Raw 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 services
import (
"context"
"fmt"
"strings"
"time"
"opsxloki/models"
"opsxloki/repository"
"opsxloki/utils"
)
type CollectorService struct{}
func NewCollectorService() *CollectorService {
return &CollectorService{}
}
type CreateCollectorRequest struct {
Name string
Type string
LokiDeploymentID uint
HostID *uint
ClusterID *uint
TargetDeployment string
Namespace string
LogPath string
Labels string
}
// fillAssociations 填充关联的 Loki / Host / Cluster(用于配置生成与展示)
func (s *CollectorService) fillAssociations(c *models.Collector) {
db := repository.GetDB()
db.RLock()
defer db.RUnlock()
for i := range db.Deployments {
if db.Deployments[i].ID == c.LokiDeploymentID {
d := db.Deployments[i]
c.LokiDeployment = &d
break
}
}
if c.HostID != nil {
for i := range db.Hosts {
if db.Hosts[i].ID == *c.HostID {
h := db.Hosts[i]
c.Host = &h
break
}
}
}
if c.ClusterID != nil {
for i := range db.Clusters {
if db.Clusters[i].ID == *c.ClusterID {
cl := db.Clusters[i]
c.Cluster = &cl
break
}
}
}
}
// generateConfigSnapshot 按类型生成配置快照
func (s *CollectorService) generateConfigSnapshot(c *models.Collector) string {
endpoint := ""
if c.LokiDeployment != nil {
endpoint = c.LokiDeployment.Endpoint
}
clusterName := "host"
if c.Cluster != nil && c.Cluster.Name != "" {
clusterName = c.Cluster.Name
}
switch c.Type {
case "host-promtail":
return PromtailConfig(endpoint, c.LogPath, "host", c.Labels)
case "k8s-daemonset":
return PromtailHelmValues(endpoint, clusterName, c.Labels)
case "k8s-fluentbit-sidecar":
host, port := parseLokiURL(endpoint)
app := c.TargetDeployment
ns := c.Namespace
if ns == "" {
ns = "default"
}
return FluentbitConfig(host, port, c.LogPath, app, ns)
}
return ""
}
func (s *CollectorService) Create(req *CreateCollectorRequest) (*models.Collector, error) {
switch req.Type {
case "host-promtail", "k8s-daemonset", "k8s-fluentbit-sidecar":
default:
return nil, fmt.Errorf("不支持的采集器类型: %s", req.Type)
}
if req.LokiDeploymentID == 0 {
return nil, fmt.Errorf("请选择关联的 Loki 部署")
}
if req.Type == "host-promtail" && (req.HostID == nil || *req.HostID == 0) {
return nil, fmt.Errorf("主机采集器需要选择目标主机")
}
if (req.Type == "k8s-daemonset" || req.Type == "k8s-fluentbit-sidecar") && (req.ClusterID == nil || *req.ClusterID == 0) {
return nil, fmt.Errorf("K8s 采集器需要选择目标集群")
}
if req.Type == "k8s-fluentbit-sidecar" && req.TargetDeployment == "" {
return nil, fmt.Errorf("Sidecar 采集器需要填写目标 Deployment 名称")
}
refType := "cluster"
if req.Type == "host-promtail" {
refType = "host"
}
collector := models.Collector{
ID: repository.GetDB().NextID("collectors"),
Name: req.Name,
Type: req.Type,
LokiDeploymentID: req.LokiDeploymentID,
RefType: refType,
HostID: req.HostID,
ClusterID: req.ClusterID,
TargetDeployment: req.TargetDeployment,
Namespace: req.Namespace,
LogPath: req.LogPath,
Labels: req.Labels,
Status: "pending",
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
}
s.fillAssociations(&collector)
if collector.LokiDeployment == nil {
return nil, fmt.Errorf("关联的 Loki 部署不存在")
}
collector.Config = s.generateConfigSnapshot(&collector)
db := repository.GetDB()
db.Lock()
db.Collectors = append(db.Collectors, collector)
db.Unlock()
db.RequestSave()
go s.Install(collector.ID)
return &collector, nil
}
func (s *CollectorService) Get(id uint) (*models.Collector, error) {
db := repository.GetDB()
db.RLock()
var found *models.Collector
for i := range db.Collectors {
if db.Collectors[i].ID == id {
c := db.Collectors[i]
found = &c
break
}
}
db.RUnlock()
if found == nil {
return nil, utils.ErrNotFound
}
s.fillAssociations(found)
return found, nil
}
func (s *CollectorService) List(page, pageSize int) ([]models.Collector, int64, error) {
db := repository.GetDB()
db.RLock()
total := int64(len(db.Collectors))
start := (page - 1) * pageSize
if start < 0 || start >= len(db.Collectors) {
db.RUnlock()
return []models.Collector{}, total, nil
}
end := start + pageSize
if end > len(db.Collectors) {
end = len(db.Collectors)
}
result := make([]models.Collector, end-start)
copy(result, db.Collectors[start:end])
db.RUnlock()
for i := range result {
s.fillAssociations(&result[i])
}
return result, total, nil
}
func (s *CollectorService) Update(id uint, updates map[string]interface{}) error {
db := repository.GetDB()
db.Lock()
defer db.Unlock()
for i := range db.Collectors {
if db.Collectors[i].ID == id {
if v, ok := updates["name"].(string); ok && v != "" {
db.Collectors[i].Name = v
}
if v, ok := updates["status"].(string); ok && v != "" {
db.Collectors[i].Status = v
}
if v, ok := updates["statusMessage"].(string); ok {
db.Collectors[i].StatusMessage = v
}
if v, ok := updates["logPath"].(string); ok {
if v != db.Collectors[i].LogPath {
db.Collectors[i].StatusMessage = "采集路径已保存,点击部署以应用新配置"
}
db.Collectors[i].LogPath = v
}
if v, ok := updates["labels"].(string); ok {
db.Collectors[i].Labels = v
}
// targetNamespaces: 采集哪些命名空间([]string;空=全集群)
if v, ok := updates["targetNamespaces"]; ok {
var nss []string
switch t := v.(type) {
case []interface{}:
for _, item := range t {
if s, ok := item.(string); ok {
if s = strings.TrimSpace(s); s != "" {
nss = append(nss, s)
}
}
}
case []string:
for _, s := range t {
if s = strings.TrimSpace(s); s != "" {
nss = append(nss, s)
}
}
case string:
// 兼容 "astralx,log" 这种逗号分隔写法
for _, s := range strings.Split(t, ",") {
if s = strings.TrimSpace(s); s != "" {
nss = append(nss, s)
}
}
}
if len(nss) != len(db.Collectors[i].TargetNamespaces) {
db.Collectors[i].StatusMessage = "采集命名空间已保存,点击部署以应用新配置"
} else {
for j := range nss {
if nss[j] != db.Collectors[i].TargetNamespaces[j] {
db.Collectors[i].StatusMessage = "采集命名空间已保存,点击部署以应用新配置"
break
}
}
}
db.Collectors[i].TargetNamespaces = nss
}
// appLabelSource:app 标签取值来源(空=默认 controller_name 回退链)
if v, ok := updates["appLabelSource"].(string); ok {
if strings.TrimSpace(v) != db.Collectors[i].AppLabelSource {
db.Collectors[i].StatusMessage = "app 标签来源已保存,点击部署以应用新配置"
}
db.Collectors[i].AppLabelSource = strings.TrimSpace(v)
}
// appInclude:只采集这些 app(白名单,空=不限)
if v, ok := updates["appInclude"]; ok {
var apps []string
switch t := v.(type) {
case []interface{}:
for _, item := range t {
if s, ok := item.(string); ok {
if s = strings.TrimSpace(s); s != "" {
apps = append(apps, s)
}
}
}
case []string:
for _, s := range t {
if s = strings.TrimSpace(s); s != "" {
apps = append(apps, s)
}
}
case string:
for _, s := range strings.Split(t, ",") {
if s = strings.TrimSpace(s); s != "" {
apps = append(apps, s)
}
}
}
if len(apps) != len(db.Collectors[i].AppInclude) {
db.Collectors[i].StatusMessage = "采集 app 白名单已保存,点击部署以应用新配置"
}
db.Collectors[i].AppInclude = apps
}
db.Collectors[i].UpdatedAt = time.Now()
db.RequestSave()
return nil
}
}
return utils.ErrNotFound
}
func (s *CollectorService) Delete(id uint) error {
collector, err := s.Get(id)
if err != nil {
return utils.ErrNotFound
}
db := repository.GetDB()
db.Lock()
for i, c := range db.Collectors {
if c.ID == id {
db.Collectors = append(db.Collectors[:i], db.Collectors[i+1:]...)
break
}
}
db.Unlock()
db.RequestSave()
// 异步卸载(用拷贝的 collector,避免删除后取不到)
go func() {
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
defer cancel()
NewCollectorDeployer(collector, collector.LokiDeployment, nil).Uninstall(ctx)
}()
return nil
}
// Install 异步部署采集器
func (s *CollectorService) Install(id uint) {
collector, err := s.Get(id)
if err != nil {
return
}
if collector.LokiDeployment == nil {
s.Update(id, map[string]interface{}{"status": "error", "statusMessage": "关联的 Loki 不存在"})
return
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Minute)
defer cancel()
if err := NewCollectorDeployer(collector, collector.LokiDeployment, nil).Install(ctx); err != nil {
s.Update(id, map[string]interface{}{"status": "error", "statusMessage": err.Error()})
return
}
s.Update(id, map[string]interface{}{"status": "active", "statusMessage": "采集器已部署"})
}
// Uninstall 异步卸载采集器
func (s *CollectorService) Uninstall(id uint) {
collector, err := s.Get(id)
if err != nil {
return
}
if collector.LokiDeployment == nil {
return
}
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
defer cancel()
NewCollectorDeployer(collector, collector.LokiDeployment, nil).Uninstall(ctx)
s.Update(id, map[string]interface{}{"status": "pending", "statusMessage": "采集器已卸载"})
}