From 41633f0b3890c580ef69f80516a1f0c6a5349c33 Mon Sep 17 00:00:00 2001 From: opsx Date: Tue, 27 Oct 2026 03:55:13 +0000 Subject: [PATCH] =?UTF-8?q?feat(collector):=20=E9=87=87=E9=9B=86=E5=91=BD?= =?UTF-8?q?=E5=90=8D=E7=A9=BA=E9=97=B4=E5=A4=9A=E9=80=89=20+=20app=20?= =?UTF-8?q?=E6=A0=87=E7=AD=BE=E6=9D=A5=E6=BA=90=E5=8F=AF=E8=87=AA=E5=AE=9A?= =?UTF-8?q?=E4=B9=89=20+=20app=20=E7=99=BD=E5=90=8D=E5=8D=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit === 一、采集命名空间改为多选(原来只有文本输入)=== - 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 --- backend/models/collector.go | 7 + backend/services/collector.go | 37 ++++ backend/services/collector_deployer.go | 3 +- backend/services/collector_templates.go | 66 +++++- frontend/src/api/collector/index.ts | 8 +- .../components/CollectorEditDialog.tsx | 188 +++++++++++++++--- frontend/src/types/index.ts | 4 + 7 files changed, 280 insertions(+), 33 deletions(-) diff --git a/backend/models/collector.go b/backend/models/collector.go index a9857a0..a5df2b8 100644 --- a/backend/models/collector.go +++ b/backend/models/collector.go @@ -16,6 +16,13 @@ type Collector struct { Cluster *Cluster `json:"cluster,omitempty"` TargetDeployment string `json:"targetDeployment"` // sidecar 专用:目标 deployment 名 Namespace string `json:"namespace"` // 采集器自身部署所在的 k8s 命名空间 + // AppLabelSource 决定 app 标签从哪里取值(可填私有标签,便于按自己的规范匹配)。 + // 留空 = 默认:app.kubernetes.io/name → app → Pod 的 controller_name(去 hash) → pod 名 + // 可填:controller_name / app.kubernetes.io/name / app / k8s.kuboard.cn/name 等任意 Pod 标签 + AppLabelSource string `json:"appLabelSource"` + // AppInclude 只采集这些 app 的日志(白名单,按 AppLabelSource 解析出的 app 值匹配)。 + // 留空 = 不限制。配合 TargetNamespaces 可显著收敛日志量。 + AppInclude []string `json:"appInclude"` // TargetNamespaces 只采集这些命名空间的 Pod 日志;为空表示采集全集群。 // 对应 promtail 里 kubernetes_sd_configs.namespaces.names TargetNamespaces []string `json:"targetNamespaces"` diff --git a/backend/services/collector.go b/backend/services/collector.go index 7aec52c..820a02c 100644 --- a/backend/services/collector.go +++ b/backend/services/collector.go @@ -248,6 +248,43 @@ func (s *CollectorService) Update(id uint, updates map[string]interface{}) error } 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 diff --git a/backend/services/collector_deployer.go b/backend/services/collector_deployer.go index bbaf1eb..44b8405 100644 --- a/backend/services/collector_deployer.go +++ b/backend/services/collector_deployer.go @@ -204,7 +204,8 @@ func (d *CollectorDeployer) installDaemonset(ctx context.Context, endpoint strin for _, kind := range []string{"daemonset", "configmap", "serviceaccount"} { _ = deleteK8sResource(ctx, cluster, kind, ns, "promtail") } - manifest := PromtailDaemonsetYAML(ns, endpoint, clusterName, d.collector.Labels, d.collector.TargetNamespaces) + manifest := PromtailDaemonsetYAML(ns, endpoint, clusterName, d.collector.Labels, + d.collector.TargetNamespaces, d.collector.AppLabelSource, d.collector.AppInclude) if err := d.applyYAML(ctx, nil, manifest, "promtail"); err != nil { return err } diff --git a/backend/services/collector_templates.go b/backend/services/collector_templates.go index cb6e740..e28d5b7 100644 --- a/backend/services/collector_templates.go +++ b/backend/services/collector_templates.go @@ -3,6 +3,7 @@ package services import ( "encoding/json" "fmt" + "regexp" "sort" "strings" ) @@ -196,6 +197,55 @@ extraVolumeMounts: // PromtailDaemonsetYAML DaemonSet 模式纯 YAML 安装(SA/RBAC/ConfigMap/DaemonSet), // 取代 helm 安装 —— 后端无需安装 helm 二进制。 // relabel 体系与主流 helm chart 对齐:namespace/pod/container/node_name + __path__ 按 pod uid 精确匹配。 +// sanitizePromLabel 把 K8s 标签名转成 Prometheus/Promtail 的 meta label 片段。 +// +// k8s.kuboard.cn/name → k8s_kuboard_cn_name +// app.kubernetes.io/name → app_kubernetes_io_name +func sanitizePromLabel(name string) string { + var b strings.Builder + for _, r := range strings.TrimSpace(name) { + switch { + case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9', r == '_': + b.WriteRune(r) + default: + b.WriteRune('_') + } + } + return b.String() +} + +// promtailAppSourceLabel 把用户配置的 AppLabelSource 转成 Promtail 的 meta label 名。 +// 返回空串表示「用默认的四级回退链」。 +func promtailAppSourceLabel(src string) string { + src = strings.TrimSpace(src) + if src == "" || src == "controller_name" { + return "" + } + if strings.HasPrefix(src, "label:") { + src = strings.TrimPrefix(src, "label:") + } + // 已经是 __meta_ 开头就直接用 + if strings.HasPrefix(src, "__meta_kubernetes_") { + return src + } + return "__meta_kubernetes_pod_label_" + sanitizePromLabel(src) +} + +// promtailAppIncludeFilter 生成 app 白名单的 keep 规则(为空则不限)。 +func promtailAppIncludeFilter(apps []string) string { + clean := make([]string, 0, len(apps)) + for _, a := range apps { + if a = strings.TrimSpace(a); a != "" { + clean = append(clean, regexp.QuoteMeta(a)) + } + } + if len(clean) == 0 { + return "" + } + return fmt.Sprintf("\n - source_labels: [app]\n regex: ^(%s)$\n action: keep", + strings.Join(clean, "|")) +} + // promtailNamespaceFilter 生成 kubernetes_sd_configs 的命名空间白名单。 // namespaces 为空 → 返回空串(采集全集群所有命名空间)。 func promtailNamespaceFilter(namespaces []string) string { @@ -218,10 +268,16 @@ func promtailNamespaceFilter(namespaces []string) string { return b.String() } -func PromtailDaemonsetYAML(ns, lokiURL, clusterName, labelsJSON string, targetNamespaces []string) string { +func PromtailDaemonsetYAML(ns, lokiURL, clusterName, labelsJSON string, targetNamespaces []string, appLabelSource string, appInclude []string) string { promtailImage := ResolveImageFor("promtail", "grafana/promtail", "3.4.0") extLabels := promtailExternalLabels(clusterName, labelsJSON) nsFilter := promtailNamespaceFilter(targetNamespaces) + // 自定义 app 标签来源(用户可用自己注入的私有标签),插到回退链最前面 + appSourceLine := "" + if lbl := promtailAppSourceLabel(appLabelSource); lbl != "" { + appSourceLine = " - " + lbl + "\n" + } + appIncludeFilter := promtailAppIncludeFilter(appInclude) return fmt.Sprintf(`apiVersion: v1 kind: ServiceAccount metadata: @@ -310,8 +366,10 @@ data: # # 不使用 k8s.kuboard.cn/name 之类的平台私有标签:那只是 Kuboard 管理的集群才有, # 换 Rancher/Lens/Helm/kubectl 部署的集群就不存在,会破坏通用性。 + # 如集群确实有私有规范标签,可在「采集器 → 编辑」里配置 AppLabelSource, + # 它会作为最高优先级插到下面列表最前(其余仍兜底)。 - source_labels: - - __meta_kubernetes_pod_label_app_kubernetes_io_name +%s - __meta_kubernetes_pod_label_app_kubernetes_io_name - __meta_kubernetes_pod_label_app - __tmp_controller_name - __meta_kubernetes_pod_name @@ -350,7 +408,7 @@ data: replacement: /var/log/pods/*$1/*.log separator: / source_labels: [__meta_kubernetes_pod_uid, __meta_kubernetes_pod_container_name] - target_label: __path__ + target_label: __path__%s --- apiVersion: apps/v1 kind: DaemonSet @@ -424,7 +482,7 @@ spec: - name: containers hostPath: path: /var/lib/docker/containers -`, ns, ns, ns, lokiURL, extLabels, nsFilter, ns, promtailImage) +`, ns, ns, ns, lokiURL, extLabels, nsFilter, appSourceLine, promtailImage, appIncludeFilter) } // FluentbitConfig Sidecar 模式 Fluent Bit 配置(采集 logPath,推送至 lokiHost:lokiPort) diff --git a/frontend/src/api/collector/index.ts b/frontend/src/api/collector/index.ts index e48096e..a349e0b 100644 --- a/frontend/src/api/collector/index.ts +++ b/frontend/src/api/collector/index.ts @@ -10,7 +10,13 @@ export const collectorApi = { create: (data: CreateCollectorRequest) => http.post('/collectors', data), - update: (id: number, data: { name: string; logPath: string; targetNamespaces?: string[] }) => + update: (id: number, data: { + name: string; + logPath: string; + targetNamespaces?: string[]; + appLabelSource?: string; + appInclude?: string[]; + }) => http.put<{ message: string }>(`/collectors/${id}`, data), remove: (id: number) => http.delete<{ message: string }>(`/collectors/${id}`), diff --git a/frontend/src/pages/collectors/components/CollectorEditDialog.tsx b/frontend/src/pages/collectors/components/CollectorEditDialog.tsx index 95dabfe..7df7824 100644 --- a/frontend/src/pages/collectors/components/CollectorEditDialog.tsx +++ b/frontend/src/pages/collectors/components/CollectorEditDialog.tsx @@ -1,9 +1,9 @@ -import { memo, useEffect, useState } from 'react' +import { memo, useEffect, useMemo, useState } from 'react' import { useForm } from 'react-hook-form' import { zodResolver } from '@hookform/resolvers/zod' import { z } from 'zod' import { toast } from 'sonner' -import { collectorApi } from '@/api' +import { clusterApi, collectorApi } from '@/api' import { Button } from '@/components/ui/button' import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog' import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '@/components/ui/form' @@ -13,11 +13,24 @@ import type { Collector } from '@/types' const schema = z.object({ name: z.string().trim().min(1, '请输入采集策略名称'), logPath: z.string().trim(), - /** 逗号分隔的命名空间白名单;留空 = 采集全集群 */ - targetNamespaces: z.string().trim(), + /** 采集的命名空间白名单;空数组 = 全集群 */ + targetNamespaces: z.array(z.string()), + /** app 标签取值来源;留空 = 默认 controller_name 四级回退 */ + appLabelSource: z.string().trim(), + /** 只采集这些 app(逗号分隔);留空 = 不限 */ + appIncludeRaw: z.string().trim(), }) type Values = z.infer +/** app 标签来源的常用取值(可自由填写其它 Pod 标签名) */ +const APP_SOURCE_PRESETS = [ + { value: '', label: '默认:app.kubernetes.io/name → app → controller 名 → pod 名' }, + { value: 'controller_name', label: 'controller_name(Pod 的 ReplicaSet 名,去 hash = Deployment 名)' }, + { value: 'app.kubernetes.io/name', label: 'app.kubernetes.io/name(K8s 标准标签)' }, + { value: 'app', label: 'app(常见简写)' }, + { value: 'k8s.kuboard.cn/name', label: 'k8s.kuboard.cn/name(Kuboard 注入)' }, +] + interface Props { collector: Collector | null onOpenChange: (open: boolean) => void @@ -26,21 +39,63 @@ interface Props { export const CollectorEditDialog = memo(function CollectorEditDialog({ collector, onOpenChange, onSaved }: Props) { const [saving, setSaving] = useState(false) + const [namespaces, setNamespaces] = useState([]) + const [nsLoading, setNsLoading] = useState(false) + const [nsError, setNsError] = useState('') + const [nsFilter, setNsFilter] = useState('') + const form = useForm({ resolver: zodResolver(schema), - defaultValues: { name: '', logPath: '', targetNamespaces: '' }, + defaultValues: { name: '', logPath: '', targetNamespaces: [], appLabelSource: '', appIncludeRaw: '' }, }) + const isDaemonSet = collector?.type === 'k8s-daemonset' + const supportsPath = !isDaemonSet + const selectedNs = form.watch('targetNamespaces') + useEffect(() => { - if (collector) { - form.reset({ - name: collector.name, - logPath: collector.logPath || '', - targetNamespaces: (collector.targetNamespaces ?? []).join(', '), - }) - } + if (!collector) return + form.reset({ + name: collector.name, + logPath: collector.logPath || '', + targetNamespaces: collector.targetNamespaces ?? [], + appLabelSource: collector.appLabelSource ?? '', + appIncludeRaw: (collector.appInclude ?? []).join(', '), + }) + setNsFilter('') }, [collector, form]) + // 拉集群命名空间列表(供多选) + useEffect(() => { + if (!collector || !isDaemonSet) return + const clusterID = collector.clusterID + if (!clusterID) return + let active = true + setNsLoading(true) + setNsError('') + clusterApi.namespaces(clusterID) + .then((res) => { if (active) setNamespaces(res?.namespaces ?? []) }) + .catch((err) => { if (active) setNsError(err instanceof Error ? err.message : '加载命名空间失败') }) + .finally(() => { if (active) setNsLoading(false) }) + return () => { active = false } + }, [collector, isDaemonSet]) + + const filteredNs = useMemo( + () => namespaces.filter((n) => n.toLowerCase().includes(nsFilter.toLowerCase())), + [namespaces, nsFilter], + ) + + const allSelected = selectedNs.length === 0 // 空 = 全集群 + + function toggleNs(ns: string) { + const next = selectedNs.includes(ns) ? selectedNs.filter((n) => n !== ns) : [...selectedNs, ns] + form.setValue('targetNamespaces', next, { shouldDirty: true }) + } + + function setAll() { + form.setValue('targetNamespaces', [], { shouldDirty: true }) + } + const submit = form.handleSubmit(async (values) => { if (!collector) return setSaving(true) @@ -48,8 +103,9 @@ export const CollectorEditDialog = memo(function CollectorEditDialog({ collector await collectorApi.update(collector.id, { name: values.name, logPath: values.logPath, - // 逗号/空格分隔 → 字符串数组;留空数组表示采集全集群 - targetNamespaces: values.targetNamespaces + targetNamespaces: values.targetNamespaces, + appLabelSource: values.appLabelSource, + appInclude: values.appIncludeRaw .split(/[,\s]+/) .map((s) => s.trim()) .filter(Boolean), @@ -63,27 +119,104 @@ export const CollectorEditDialog = memo(function CollectorEditDialog({ collector } }) - const supportsPath = collector?.type !== 'k8s-daemonset' return ( - + 编辑采集策略
( 名称 )} /> - {collector?.type === 'k8s-daemonset' && ( - - 采集命名空间 - - -

- 多个命名空间用逗号分隔;留空则采集全集群所有命名空间。保存后点击「部署」应用新配置, - Promtail 会按此生成 kubernetes_sd_configs.namespaces 白名单。 -

-
- )} />} + + {isDaemonSet && ( + <> + {/* ── 采集命名空间:多选 ── */} +
+
+ 采集命名空间 + + {allSelected ? '全集群所有命名空间' : `已选 ${selectedNs.length} 个`} + +
+ {nsError &&

{nsError}

} +
+ +
+ setNsFilter(e.target.value)} + /> +
+ {nsLoading &&

加载中…

} + {!nsLoading && filteredNs.length === 0 && ( +

+ {namespaces.length === 0 ? '未取到命名空间列表(可手动输入)' : '无匹配'} +

+ )} + {filteredNs.map((ns) => ( + + ))} +
+
+
+

+ 勾「所有命名空间」= 采集全集群;否则只采集勾选的命名空间。 + 生成 Promtail 的 kubernetes_sd_configs.namespaces 白名单。 +

+
+ + {/* ── app 标签来源 ── */} + ( + + app 标签取值来源 + + + + + {APP_SOURCE_PRESETS.filter((p) => p.value).map((p) => ( + + ))} + + +

+ 默认按 app.kubernetes.io/name → app → controller 名 → pod 名 四级回退。 + 若集群有自己的私有规范标签(如 k8s.kuboard.cn/name), + 填在这里会作为最高优先级,其余仍兜底。 +

+
+ )} /> + + {/* ── app 白名单 ── */} + ( + + 只采集这些 app(可选) + + + + +

+ 填了之后 Promtail 只保留 app 命中这些值的日志(action: keep), + 是控制日志量最直接的手段。值需与上面「app 标签取值来源」解析出的 app 一致。 +

+
+ )} /> + + )} + {supportsPath && ( 采集日志路径 @@ -92,6 +225,7 @@ export const CollectorEditDialog = memo(function CollectorEditDialog({ collector

Sidecar 需使用业务容器的共享日志卷,或专用 logs 目录。保存后点击「部署」应用新配置。

)} />} +
diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index 67997be..52eee80 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -199,6 +199,10 @@ export interface Collector { namespace: string; /** 只采集这些命名空间;为空表示采集全集群 */ targetNamespaces?: string[]; + /** app 标签取值来源;留空=默认 controller_name 四级回退 */ + appLabelSource?: string; + /** 只采集这些 app 的日志(白名单);留空=不限 */ + appInclude?: string[]; logPath: string; labels: string; status: string;