feat(collectors): DaemonSet 不再填日志路径,改为「只采集哪些命名空间」多选
CI / Backend (go vet + test) (push) Canceled after 0s
CI / Frontend (lint + build + test) (push) Canceled after 0s

用户指出的三个问题,逐条核实后的结论:

1) 「采集日志路径」对 DaemonSet 确实是多余的 —— 确认后端模板里写死了
   `replacement: /var/log/pods/*$1/*.log`(按 pod UID 精确匹配容器标准输出),
   表单里填的 logPath 对 DaemonSet 完全不参与渲染。
   → 选择 DaemonSet 时隐藏该字段,提交时强制置空。

2) 「可以指定采集多个命名空间」—— 后端**早就支持**:
   models.Collector.TargetNamespaces + promtailNamespaceFilter(),
   空数组 = 全集群。只是**前端新建弹框没暴露**(编辑弹框里有)。
   → 新建弹框补上多选 chips(全集群 / 各命名空间可点选),并补齐前后端透传:
     dto.CreateCollectorRequest.TargetNamespaces
     controller → services.CreateCollectorRequest.TargetNamespaces
     models.Collector.TargetNamespaces
   之前前端即使传了也会被 Go 静默丢弃(无该字段)。

3) 「命名空间」字段语义已在上一提交补了分类型说明:
   - DaemonSet:采集器自身部署在哪个命名空间(不影响采什么)
   - Sidecar:目标应用 Deployment 所在命名空间

新增 TestDaemonsetTargetNamespacesFilter:
  nil → 不生成 namespaces 白名单(全集群)
  ["astralx"] → 生成且包含 astralx
  ["astralx","default"] → 两个都在

=== 实测(d45dfa18ecc5)===
  前端文案「只采集这些命名空间」「不选 = 采集整个集群…」均已进包
  渲染测试 3 种输入全部符合预期
  注:本次 docker push latest 遇到 Docker Hub 502,v0.1 成功而 latest 仍指向旧镜像,
     导致首次滚动后 Pod 仍是旧代码;重推 latest + 强制重建 Pod 后确认
     Pod digest = d45dfa18ecc5 —— 记录这个坑:push 失败必须核对三方 digest。
This commit is contained in:
opsx committed 2026-10-30 16:03:24 +00:00
1 parent 87909ed403
commit c8d026adc7
8 files changed
+104 -5

No files matched your search

+1 -1
View File
@@ -28,7 +28,7 @@ func (ctrl *CollectorController) Create(c *gin.Context) {
Name: req.Name, Type: req.Type, LokiDeploymentID: req.LokiDeploymentID, Name: req.Name, Type: req.Type, LokiDeploymentID: req.LokiDeploymentID,
HostID: req.HostID, ClusterID: req.ClusterID, TargetDeployment: req.TargetDeployment, HostID: req.HostID, ClusterID: req.ClusterID, TargetDeployment: req.TargetDeployment,
Namespace: req.Namespace, LogPath: req.LogPath, Labels: req.Labels, Namespace: req.Namespace, LogPath: req.LogPath, Labels: req.Labels,
ScrapeJobs: req.ScrapeJobs, ScrapeJobs: req.ScrapeJobs, TargetNamespaces: req.TargetNamespaces,
}) })
if err != nil { if err != nil {
utils.Error(c, 400, err.Error()) utils.Error(c, 400, err.Error())
+3
View File
@@ -14,4 +14,7 @@ type CreateCollectorRequest struct {
Labels string `json:"labels"` Labels string `json:"labels"`
// ScrapeJobs 主机模式的结构化采集项(可选;留空按 LogPath 生成 app + system 两个默认项) // ScrapeJobs 主机模式的结构化采集项(可选;留空按 LogPath 生成 app + system 两个默认项)
ScrapeJobs []models.ScrapeJob `json:"scrapeJobs"` ScrapeJobs []models.ScrapeJob `json:"scrapeJobs"`
// TargetNamespaces 仅 k8s-daemonset 使用:只采集这些命名空间的 Pod 日志。
// 空/不传 = 采集全集群(默认,保持旧行为)。
TargetNamespaces []string `json:"targetNamespaces"`
} }
+2
View File
@@ -57,6 +57,7 @@ type CreateCollectorRequest struct {
LogPath string LogPath string
Labels string Labels string
ScrapeJobs []models.ScrapeJob ScrapeJobs []models.ScrapeJob
TargetNamespaces []string
} }
// fillAssociations 填充关联的 Loki / Host / Cluster(用于配置生成与展示) // fillAssociations 填充关联的 Loki / Host / Cluster(用于配置生成与展示)
@@ -286,6 +287,7 @@ func (s *CollectorService) Create(req *CreateCollectorRequest) (*models.Collecto
LogPath: req.LogPath, LogPath: req.LogPath,
Labels: req.Labels, Labels: req.Labels,
ScrapeJobs: req.ScrapeJobs, ScrapeJobs: req.ScrapeJobs,
TargetNamespaces: req.TargetNamespaces,
Status: "pending", Status: "pending",
CreatedAt: time.Now(), CreatedAt: time.Now(),
UpdatedAt: time.Now(), UpdatedAt: time.Now(),
@@ -0,0 +1,37 @@
package services
import (
"strings"
"testing"
)
// 回归:DaemonSet 模式的"只采集这些命名空间"必须真正渲染进 promtail 配置,
// 否则前端多选形同虚设(用户选了 astralx,实际还是全集群在采)。
func TestDaemonsetTargetNamespacesFilter(t *testing.T) {
base := func(ns []string) string {
return PromtailDaemonsetYAML("log", "http://loki.log.svc:3100", "c1", "", ns, "", nil)
}
// 1) 空 = 全集群:不应出现 namespaces 白名单
all := base(nil)
if strings.Contains(all, "namespaces:") {
t.Error("targetNamespaces 为空时不应生成命名空间白名单(应为全集群采集)")
}
// 2) 指定一个命名空间:必须出现白名单
one := base([]string{"astralx"})
if !strings.Contains(one, "namespaces:") {
t.Fatal("targetNamespaces=[astralx] 时没有生成 namespaces 白名单")
}
if !strings.Contains(one, "astralx") {
t.Error("白名单里没有 astralx")
}
// 3) 多个命名空间都要在
multi := base([]string{"astralx", "default"})
for _, want := range []string{"astralx", "default"} {
if !strings.Contains(multi, want) {
t.Errorf("多选时白名单缺少 %q", want)
}
}
}
@@ -81,6 +81,9 @@ const collectors = {
namespaceHintSidecar: 'The namespace where the TARGET Deployment lives. The platform finds that Deployment there and injects a Fluent Bit sidecar; collected logs also carry this namespace label. It is NOT a log filter.', namespaceHintSidecar: 'The namespace where the TARGET Deployment lives. The platform finds that Deployment there and injects a Fluent Bit sidecar; collected logs also carry this namespace label. It is NOT a log filter.',
namespaceHintDaemonset: 'The namespace where the Promtail DaemonSet is deployed. The DaemonSet collects container logs from ALL namespaces in the cluster — it does not filter by namespace (usually just the Loki namespace, e.g. log).', namespaceHintDaemonset: 'The namespace where the Promtail DaemonSet is deployed. The DaemonSet collects container logs from ALL namespaces in the cluster — it does not filter by namespace (usually just the Loki namespace, e.g. log).',
namespaceHintHost: 'Not used in host mode.', namespaceHintHost: 'Not used in host mode.',
targetNamespaces: 'Only collect from these namespaces',
targetNamespacesHint: 'Leave empty to collect container stdout from ALL namespaces. Selecting specific ones cuts log volume significantly.',
targetNamespacesAll: 'All namespaces',
nsPickCluster: 'Select a cluster first', nsPickCluster: 'Select a cluster first',
nsLoading: 'Loading namespaces…', nsLoading: 'Loading namespaces…',
nsPick: 'Select a namespace', nsPick: 'Select a namespace',
@@ -85,6 +85,9 @@ const collectors = {
namespaceHintSidecar: '填「目标应用(Deployment)所在的命名空间」。平台会在该命名空间里找到目标 Deployment 并注入 Fluent Bit sidecar,采集到的日志也会打上这个 namespace 标签。它不是日志过滤条件。', namespaceHintSidecar: '填「目标应用(Deployment)所在的命名空间」。平台会在该命名空间里找到目标 Deployment 并注入 Fluent Bit sidecar,采集到的日志也会打上这个 namespace 标签。它不是日志过滤条件。',
namespaceHintDaemonset: '填「Promtail DaemonSet 部署到哪个命名空间」。DaemonSet 会采集整个集群所有命名空间的容器日志,不按命名空间过滤(一般就用 Loki 所在的 log)。', namespaceHintDaemonset: '填「Promtail DaemonSet 部署到哪个命名空间」。DaemonSet 会采集整个集群所有命名空间的容器日志,不按命名空间过滤(一般就用 Loki 所在的 log)。',
namespaceHintHost: '主机模式不使用该字段。', namespaceHintHost: '主机模式不使用该字段。',
targetNamespaces: '只采集这些命名空间',
targetNamespacesHint: '不选 = 采集整个集群所有命名空间的容器标准输出。选了就只采这几个(能显著减少日志量)。',
targetNamespacesAll: '全集群',
nsPickCluster: '先选择集群', nsPickCluster: '先选择集群',
nsLoading: '加载命名空间…', nsLoading: '加载命名空间…',
nsPick: '选择命名空间', nsPick: '选择命名空间',
@@ -63,6 +63,8 @@ function buildCollectorFormSchema(t: TFunction) {
targetDeployment: z.string(), targetDeployment: z.string(),
namespace: z.string(), namespace: z.string(),
logPath: z.string(), logPath: z.string(),
/** DaemonSet 模式:只采集这些命名空间的 Pod 日志;空数组 = 全集群 */
targetNamespaces: z.array(z.string()),
/** 旧版表单即无录入入口,仅透传(默认空串) */ /** 旧版表单即无录入入口,仅透传(默认空串) */
labels: z.string(), labels: z.string(),
}) })
@@ -100,6 +102,7 @@ const DEFAULT_VALUES: CollectorFormValues = {
targetDeployment: '', targetDeployment: '',
namespace: '', namespace: '',
logPath: '', logPath: '',
targetNamespaces: [],
labels: '', labels: '',
} }
@@ -145,6 +148,8 @@ export const CollectorFormDialog = memo(function CollectorFormDialog({
const clusterID = form.watch('clusterID') const clusterID = form.watch('clusterID')
const namespace = form.watch('namespace') const namespace = form.watch('namespace')
const isSidecarType = collectorType === 'k8s-fluentbit-sidecar' const isSidecarType = collectorType === 'k8s-fluentbit-sidecar'
const isDaemonsetType = collectorType === 'k8s-daemonset'
const selectedTargetNs = form.watch('targetNamespaces')
// 打开时重置(关闭重置时机明确) // 打开时重置(关闭重置时机明确)
useEffect(() => { useEffect(() => {
@@ -156,7 +161,7 @@ export const CollectorFormDialog = memo(function CollectorFormDialog({
}, [open, form]) }, [open, form])
useEffect(() => { useEffect(() => {
if (!open || !isSidecarType || !clusterID) return if (!open || (!isSidecarType && !isDaemonsetType) || !clusterID) return
const controller = new AbortController() const controller = new AbortController()
setNamespacesLoading(true) setNamespacesLoading(true)
setNamespacesError('') setNamespacesError('')
@@ -208,7 +213,10 @@ export const CollectorFormDialog = memo(function CollectorFormDialog({
targetDeployment: targetDeployment:
values.type === 'k8s-fluentbit-sidecar' ? values.targetDeployment : undefined, values.type === 'k8s-fluentbit-sidecar' ? values.targetDeployment : undefined,
namespace: values.type !== 'host-promtail' ? values.namespace : undefined, namespace: values.type !== 'host-promtail' ? values.namespace : undefined,
logPath: values.logPath, // DaemonSet 的日志路径由平台固定为 /var/log/pods/<uid>/*.log,不需要用户填;
// 真要限制范围用 targetNamespaces(空 = 全集群)
logPath: values.type === 'k8s-daemonset' ? '' : values.logPath,
targetNamespaces: values.type === 'k8s-daemonset' ? values.targetNamespaces : undefined,
labels: values.labels, labels: values.labels,
}) })
onOpenChange(false) onOpenChange(false)
@@ -447,7 +455,48 @@ export const CollectorFormDialog = memo(function CollectorFormDialog({
</> </>
)} )}
<FormField {/* DaemonSet 不需要填日志路径:平台固定用 /var/log/pods/<podUID>/*.log
(容器标准输出)。这里改成"只采集哪些命名空间",空 = 全集群。 */}
{isDaemonsetType && (
<div>
<p className="text-sm font-medium">{t('form.targetNamespaces')}</p>
<p className="mt-1 text-xs text-muted-foreground">{t('form.targetNamespacesHint')}</p>
<div className="mt-2 max-h-40 overflow-y-auto rounded-md border p-2">
{namespacesLoading ? (
<p className="text-xs text-muted-foreground">{t('form.nsLoading')}</p>
) : !clusterID ? (
<p className="text-xs text-muted-foreground">{t('form.nsPickCluster')}</p>
) : namespaces.length === 0 ? (
<p className="text-xs text-muted-foreground">{t('form.nsEmpty')}</p>
) : (
<div className="flex flex-wrap gap-2">
<button
type="button"
onClick={() => form.setValue('targetNamespaces', [], { shouldDirty: true })}
className={`rounded-full border px-3 py-1 text-xs ${selectedTargetNs.length === 0 ? 'border-primary bg-primary/10 text-primary' : 'text-muted-foreground'}`}
>
{t('form.targetNamespacesAll')}
</button>
{namespaces.map(ns => {
const active = selectedTargetNs.includes(ns)
return (
<button
key={ns}
type="button"
onClick={() => form.setValue('targetNamespaces', active ? selectedTargetNs.filter(n => n !== ns) : [...selectedTargetNs, ns], { shouldDirty: true })}
className={`rounded-full border px-3 py-1 text-xs ${active ? 'border-primary bg-primary/10 text-primary' : 'text-muted-foreground'}`}
>
{ns}
</button>
)
})}
</div>
)}
</div>
</div>
)}
{!isDaemonsetType && <FormField
control={form.control} control={form.control}
name="logPath" name="logPath"
render={({ field }) => ( render={({ field }) => (
@@ -468,7 +517,7 @@ export const CollectorFormDialog = memo(function CollectorFormDialog({
{isSidecarType && <p className="text-xs text-muted-foreground">{t('form.logPathSidecarHint')}</p>} {isSidecarType && <p className="text-xs text-muted-foreground">{t('form.logPathSidecarHint')}</p>}
</FormItem> </FormItem>
)} )}
/> />}
<FormField <FormField
control={form.control} control={form.control}
+2
View File
@@ -243,6 +243,8 @@ export interface CreateCollectorRequest {
namespace?: string; namespace?: string;
logPath?: string; logPath?: string;
labels?: string; labels?: string;
/** DaemonSet 模式:只采集这些命名空间的 Pod 日志;空数组/不传 = 全集群 */
targetNamespaces?: string[];
} }
// Dashboard Stats // Dashboard Stats