From 1580c62d0cc512dd6dcbebd55d9a41ec68effa7e Mon Sep 17 00:00:00 2001 From: Zara1024 Date: Tue, 29 Sep 2026 11:38:25 +0800 Subject: [PATCH 1/9] feat(notify): upgrade feishu alert notifications to interactive cards with rich chinese formatting --- internal/manager/biz/alert/pipeline.go | 10 +- internal/pkg/notify/notify_test.go | 76 ++++++++- internal/pkg/notify/webhook.go | 219 ++++++++++++++++++++++++- 3 files changed, 298 insertions(+), 7 deletions(-) diff --git a/internal/manager/biz/alert/pipeline.go b/internal/manager/biz/alert/pipeline.go index 5d802eb52..3c2099dce 100644 --- a/internal/manager/biz/alert/pipeline.go +++ b/internal/manager/biz/alert/pipeline.go @@ -423,16 +423,21 @@ func (e *PipelineEvaluator) notify(ctx context.Context, res *FiringResult, summa OccurredAt: at, Labels: map[string]string{ "rule": res.Incident.Rule, + "rule_name": res.Incident.RuleName, + "status": res.Incident.Status, "incident_id": fmt.Sprintf("%d", res.Incident.ID), }, } + if res.Incident.RunbookURL != "" { + msg.Labels["runbook_url"] = res.Incident.RunbookURL + } // Preserve application identity for notification routing and correlation. // Control labels such as incident_id/rule still come from the incident. if labels, err := res.Incident.Labels(); err != nil { e.log.Warn("alert: decode notification identity failed", slog.Uint64("incident_id", res.Incident.ID), slog.Any("err", err)) } else { - for _, key := range []string{"service", "service_namespace", "deployment_environment_name", "service_instance_id", "span_name"} { - if value, ok := labels[key]; ok { + for _, key := range []string{"service", "service_namespace", "deployment_environment_name", "service_instance_id", "span_name", "rule_name", "rule_expr"} { + if value, ok := labels[key]; ok && msg.Labels[key] == "" { msg.Labels[key] = value } } @@ -446,6 +451,7 @@ func (e *PipelineEvaluator) notify(ctx context.Context, res *FiringResult, summa e.log.Warn("alert: resolve device identity for notification failed", slog.Uint64("device_id", deviceID), slog.Any("err", err)) } else if display := deviceDisplay(identity); display != "" { + msg.Labels["device_name"] = display msg.Subject = strings.ReplaceAll(msg.Subject, fmt.Sprintf("device_id=%d", deviceID), "device="+display) if identity.Hostname != "" { diff --git a/internal/pkg/notify/notify_test.go b/internal/pkg/notify/notify_test.go index 398655e93..4687c2cf3 100644 --- a/internal/pkg/notify/notify_test.go +++ b/internal/pkg/notify/notify_test.go @@ -78,8 +78,11 @@ func TestFeishuSenderSignsPayload(t *testing.T) { if payload["sign"] == "" { t.Errorf("sign missing") } - if payload["msg_type"] != "text" { - t.Errorf("msg_type = %v", payload["msg_type"]) + if payload["msg_type"] != "interactive" { + t.Errorf("msg_type = %v, want interactive", payload["msg_type"]) + } + if payload["card"] == nil { + t.Errorf("card missing in payload") } w.WriteHeader(http.StatusOK) })) @@ -92,6 +95,75 @@ func TestFeishuSenderSignsPayload(t *testing.T) { } } +func TestFeishuSenderEmitsInteractiveCard(t *testing.T) { + var got map[string]any + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if err := json.NewDecoder(r.Body).Decode(&got); err != nil { + t.Errorf("decode payload: %v", err) + } + w.WriteHeader(http.StatusOK) + })) + defer srv.Close() + + sender := NewFeishuSender("feishu-ops", srv.URL, "", srv.Client()) + occurred := time.Date(2026, 9, 29, 10, 0, 0, 0, time.UTC) + err := sender.Send(context.Background(), Message{ + Subject: "设备离线: 阿里云ECS01 心跳超时", + Severity: SeverityCritical, + Source: "global", + DedupeKey: "pipeline:device_offline:1", + OccurredAt: occurred, + Labels: map[string]string{ + "rule": "device_offline", + "rule_name": "设备离线", + "device_id": "1", + "device_name": "阿里云ECS01", + "incident_id": "88", + "runbook_url": "https://wiki.ops/runbook/device_offline", + }, + }) + if err != nil { + t.Fatalf("Send: %v", err) + } + + if got["msg_type"] != "interactive" { + t.Fatalf("msg_type = %v, want interactive", got["msg_type"]) + } + + card, ok := got["card"].(map[string]any) + if !ok { + t.Fatalf("card not a map: %v", got["card"]) + } + + header, ok := card["header"].(map[string]any) + if !ok || header["template"] != "red" { + t.Errorf("header template = %v, want red", header["template"]) + } + + elements, ok := card["elements"].([]any) + if !ok || len(elements) == 0 { + t.Fatalf("elements missing or empty") + } + + // Verify resolved card turns green + err = sender.Send(context.Background(), Message{ + Subject: "设备已恢复正常", + Severity: SeverityInfo, + Labels: map[string]string{ + "status": "resolved", + }, + }) + if err != nil { + t.Fatalf("Send resolved: %v", err) + } + cardResolved := got["card"].(map[string]any) + headerResolved := cardResolved["header"].(map[string]any) + if headerResolved["template"] != "green" { + t.Errorf("resolved header template = %v, want green", headerResolved["template"]) + } +} + + func TestDingTalkSenderSignsURL(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.URL.Query().Get("timestamp") == "" { diff --git a/internal/pkg/notify/webhook.go b/internal/pkg/notify/webhook.go index 68f1e17bc..e43b0381d 100644 --- a/internal/pkg/notify/webhook.go +++ b/internal/pkg/notify/webhook.go @@ -138,12 +138,12 @@ func nonEmpty(v, fallback string) string { return fallback } -// NewFeishuSender posts a text payload compatible with Feishu/Lark custom bots. +// NewFeishuSender posts an interactive card payload compatible with Feishu/Lark custom bots. func NewFeishuSender(name, endpoint, secret string, client *http.Client) Sender { return newWebhookSender(name, endpoint, secret, client, func(msg Message) (any, error) { payload := map[string]any{ - "msg_type": "text", - "content": map[string]string{"text": formatText(msg)}, + "msg_type": "interactive", + "card": formatFeishuCard(msg), } if secret != "" { ts := fmt.Sprintf("%d", time.Now().Unix()) @@ -154,6 +154,219 @@ func NewFeishuSender(name, endpoint, secret string, client *http.Client) Sender }, nil) } +// formatFeishuCard renders a Message as a Feishu interactive message card (Card 2.0). +// It maps severity onto card header colors (red for critical, orange for warning, +// green for resolved/info) and formats structured Chinese fields and action buttons. +func formatFeishuCard(msg Message) map[string]any { + colorTemplate := "grey" + prefix := "🔔【系统通知】" + + isResolved := false + if status, ok := msg.Labels["status"]; ok && strings.EqualFold(status, "resolved") { + isResolved = true + } else if strings.Contains(strings.ToLower(msg.Subject), "resolved") { + isResolved = true + } + + if isResolved { + colorTemplate = "green" + prefix = "✅【告警恢复】" + } else { + switch msg.Severity { + case SeverityCritical: + colorTemplate = "red" + prefix = "🚨【严重告警】" + case SeverityWarning: + colorTemplate = "orange" + prefix = "⚠️【预警提示】" + case SeverityInfo: + colorTemplate = "blue" + prefix = "ℹ️【通知提醒】" + default: + colorTemplate = "grey" + prefix = "🔔【系统通知】" + } + } + + ruleTitle := msg.Subject + if rn, ok := msg.Labels["rule_name"]; ok && rn != "" { + if dn, ok2 := msg.Labels["device_name"]; ok2 && dn != "" { + ruleTitle = fmt.Sprintf("%s - %s", rn, dn) + } else { + ruleTitle = rn + } + } + if ruleTitle == "" { + ruleTitle = "告警事件通知" + } + + header := map[string]any{ + "template": colorTemplate, + "title": map[string]any{ + "tag": "plain_text", + "content": prefix + ruleTitle, + }, + } + + elements := make([]any, 0, 6) + + // Fields grid (2 columns) + fields := make([]map[string]any, 0, 6) + addField := func(title, val string) { + if val == "" { + return + } + fields = append(fields, map[string]any{ + "is_short": true, + "text": map[string]any{ + "tag": "lark_md", + "content": fmt.Sprintf("**%s**:\n%s", title, val), + }, + }) + } + + // 1. 告警级别 + if isResolved { + addField("告警状态", "RESOLVED (已恢复)") + } else { + switch msg.Severity { + case SeverityCritical: + addField("告警级别", "CRITICAL (严重)") + case SeverityWarning: + addField("告警级别", "WARNING (警告)") + case SeverityInfo: + addField("告警级别", "INFO (信息)") + default: + addField("告警级别", strings.ToUpper(string(msg.Severity))) + } + } + + // 2. 告警对象 + if dn, ok := msg.Labels["device_name"]; ok && dn != "" { + if did, ok2 := msg.Labels["device_id"]; ok2 && did != "" { + addField("告警对象", fmt.Sprintf("%s (ID: %s)", dn, did)) + } else { + addField("告警对象", dn) + } + } else if did, ok := msg.Labels["device_id"]; ok && did != "" { + addField("告警对象", "设备 #"+did) + } else if svc, ok := msg.Labels["service"]; ok && svc != "" { + addField("告警服务", svc) + } + + // 3. 规则名称 + if rn, ok := msg.Labels["rule_name"]; ok && rn != "" { + if rk, ok2 := msg.Labels["rule"]; ok2 && rk != "" && rk != rn { + addField("规则名称", fmt.Sprintf("%s (`%s`)", rn, rk)) + } else { + addField("规则名称", rn) + } + } else if rk, ok := msg.Labels["rule"]; ok && rk != "" { + addField("规则名称", fmt.Sprintf("`%s`", rk)) + } + + // 4. 触发时间 + occurredAt := msg.OccurredAt + if occurredAt.IsZero() { + occurredAt = time.Now() + } + addField("发生时间", occurredAt.Local().Format("2006-01-02 15:04:05")) + + if len(fields) > 0 { + elements = append(elements, map[string]any{ + "tag": "div", + "fields": fields, + }) + } + + // 5. 详细描述 / 触发条件 + descContent := "" + if msg.Body != "" { + descContent = msg.Body + } else if expr, ok := msg.Labels["rule_expr"]; ok && expr != "" { + descContent = fmt.Sprintf("**触发条件**:`%s`\n%s", expr, msg.Subject) + } else if msg.Subject != "" && msg.Subject != ruleTitle { + descContent = fmt.Sprintf("**详情**:%s", msg.Subject) + } + + if descContent != "" { + elements = append(elements, map[string]any{ + "tag": "div", + "text": map[string]any{ + "tag": "lark_md", + "content": descContent, + }, + }) + } + + // 6. 排查建议 (Runbook) + if rb, ok := msg.Labels["runbook_url"]; ok && rb != "" { + elements = append(elements, map[string]any{ + "tag": "div", + "text": map[string]any{ + "tag": "lark_md", + "content": fmt.Sprintf("📖 **运维手册**:[点击查看处理指引](%s)", rb), + }, + }) + } + + // 7. 操作按钮 (Actions) + actions := make([]map[string]any, 0, 2) + baseURL := "http://43.163.118.26:8080" + if u, ok := msg.Labels["console_url"]; ok && u != "" { + baseURL = u + } + + if did, ok := msg.Labels["device_id"]; ok && did != "" { + actions = append(actions, map[string]any{ + "tag": "button", + "text": map[string]any{"tag": "plain_text", "content": "🔍 查看设备详情"}, + "type": "primary", + "url": fmt.Sprintf("%s/devices/%s", baseURL, did), + }) + } + if iid, ok := msg.Labels["incident_id"]; ok && iid != "" { + actions = append(actions, map[string]any{ + "tag": "button", + "text": map[string]any{"tag": "plain_text", "content": "📋 告警事件"}, + "type": "default", + "url": fmt.Sprintf("%s/alerts", baseURL), + }) + } + + if len(actions) > 0 { + elements = append(elements, map[string]any{"tag": "hr"}) + elements = append(elements, map[string]any{ + "tag": "action", + "actions": actions, + }) + } + + // 8. 备注 (Note) + source := msg.Source + if source == "" { + source = "global" + } + elements = append(elements, map[string]any{ + "tag": "note", + "elements": []any{ + map[string]any{ + "tag": "plain_text", + "content": fmt.Sprintf("推送来源:Ongrid AIOps 智能运维平台 | 来源: %s", source), + }, + }, + }) + + return map[string]any{ + "config": map[string]any{ + "wide_screen_mode": true, + }, + "header": header, + "elements": elements, + } +} + + // NewDingTalkSender posts a text payload compatible with DingTalk custom bots. func NewDingTalkSender(name, endpoint, secret string, client *http.Client) Sender { return newWebhookSender(name, endpoint, secret, client, func(msg Message) (any, error) { From 37e679a5ca3599ea42ca796d54a6d7b1ce3b1b47 Mon Sep 17 00:00:00 2001 From: Zara1024 Date: Tue, 29 Sep 2026 12:05:19 +0800 Subject: [PATCH 2/9] fix(notify): remove hardcoded baseURL and render actions conditionally --- internal/pkg/notify/notify_test.go | 13 +++++++ internal/pkg/notify/webhook.go | 56 ++++++++++++++---------------- 2 files changed, 40 insertions(+), 29 deletions(-) diff --git a/internal/pkg/notify/notify_test.go b/internal/pkg/notify/notify_test.go index 4687c2cf3..6d62080df 100644 --- a/internal/pkg/notify/notify_test.go +++ b/internal/pkg/notify/notify_test.go @@ -120,6 +120,7 @@ func TestFeishuSenderEmitsInteractiveCard(t *testing.T) { "device_name": "阿里云ECS01", "incident_id": "88", "runbook_url": "https://wiki.ops/runbook/device_offline", + "console_url": "https://ongrid.example.com", }, }) if err != nil { @@ -145,6 +146,18 @@ func TestFeishuSenderEmitsInteractiveCard(t *testing.T) { t.Fatalf("elements missing or empty") } + // Verify action buttons are rendered when console_url is provided + hasActions := false + for _, el := range elements { + if elm, ok := el.(map[string]any); ok && elm["tag"] == "action" { + hasActions = true + break + } + } + if !hasActions { + t.Errorf("expected action element in card elements, got %v", elements) + } + // Verify resolved card turns green err = sender.Send(context.Background(), Message{ Subject: "设备已恢复正常", diff --git a/internal/pkg/notify/webhook.go b/internal/pkg/notify/webhook.go index e43b0381d..29cd121c2 100644 --- a/internal/pkg/notify/webhook.go +++ b/internal/pkg/notify/webhook.go @@ -310,36 +310,34 @@ func formatFeishuCard(msg Message) map[string]any { }) } - // 7. 操作按钮 (Actions) - actions := make([]map[string]any, 0, 2) - baseURL := "http://43.163.118.26:8080" - if u, ok := msg.Labels["console_url"]; ok && u != "" { - baseURL = u - } - - if did, ok := msg.Labels["device_id"]; ok && did != "" { - actions = append(actions, map[string]any{ - "tag": "button", - "text": map[string]any{"tag": "plain_text", "content": "🔍 查看设备详情"}, - "type": "primary", - "url": fmt.Sprintf("%s/devices/%s", baseURL, did), - }) - } - if iid, ok := msg.Labels["incident_id"]; ok && iid != "" { - actions = append(actions, map[string]any{ - "tag": "button", - "text": map[string]any{"tag": "plain_text", "content": "📋 告警事件"}, - "type": "default", - "url": fmt.Sprintf("%s/alerts", baseURL), - }) - } + // 7. 操作按钮 (Actions) - 仅在提供控制台根路径时生成跳转按钮,避免硬编码地址 + baseURL := strings.TrimRight(strings.TrimSpace(msg.Labels["console_url"]), "/") + if baseURL != "" { + actions := make([]map[string]any, 0, 2) + if did, ok := msg.Labels["device_id"]; ok && did != "" { + actions = append(actions, map[string]any{ + "tag": "button", + "text": map[string]any{"tag": "plain_text", "content": "🔍 查看设备详情"}, + "type": "primary", + "url": fmt.Sprintf("%s/devices/%s", baseURL, did), + }) + } + if iid, ok := msg.Labels["incident_id"]; ok && iid != "" { + actions = append(actions, map[string]any{ + "tag": "button", + "text": map[string]any{"tag": "plain_text", "content": "📋 告警事件"}, + "type": "default", + "url": fmt.Sprintf("%s/alerts", baseURL), + }) + } - if len(actions) > 0 { - elements = append(elements, map[string]any{"tag": "hr"}) - elements = append(elements, map[string]any{ - "tag": "action", - "actions": actions, - }) + if len(actions) > 0 { + elements = append(elements, map[string]any{"tag": "hr"}) + elements = append(elements, map[string]any{ + "tag": "action", + "actions": actions, + }) + } } // 8. 备注 (Note) From 08418042a5b9c375c146e27b83cea6a3d064bdb9 Mon Sep 17 00:00:00 2001 From: Zara1024 Date: Tue, 29 Sep 2026 12:11:52 +0800 Subject: [PATCH 3/9] fix(notify): convert occurredAt to CST timezone and support all severity/status levels --- internal/pkg/notify/notify_test.go | 59 ++++++++++++++++++------ internal/pkg/notify/webhook.go | 73 ++++++++++++++++++++---------- 2 files changed, 94 insertions(+), 38 deletions(-) diff --git a/internal/pkg/notify/notify_test.go b/internal/pkg/notify/notify_test.go index 6d62080df..232d0fa13 100644 --- a/internal/pkg/notify/notify_test.go +++ b/internal/pkg/notify/notify_test.go @@ -158,21 +158,52 @@ func TestFeishuSenderEmitsInteractiveCard(t *testing.T) { t.Errorf("expected action element in card elements, got %v", elements) } - // Verify resolved card turns green - err = sender.Send(context.Background(), Message{ - Subject: "设备已恢复正常", - Severity: SeverityInfo, - Labels: map[string]string{ - "status": "resolved", - }, - }) - if err != nil { - t.Fatalf("Send resolved: %v", err) + // Verify timezone is converted to Beijing time (+8 hours from UTC) + // occurred is 2026-09-29 03:30:00 UTC => 2026-09-29 11:30:00 CST + fieldsElem := elements[0].(map[string]any)["fields"].([]map[string]any) + foundTime := false + for _, f := range fieldsElem { + textMap := f["text"].(map[string]any) + content := textMap["content"].(string) + if strings.Contains(content, "发生时间") { + foundTime = true + if !strings.Contains(content, "2026-09-29 11:30:00") { + t.Errorf("expected occurredAt in CST '2026-09-29 11:30:00', got %s", content) + } + } + } + if !foundTime { + t.Errorf("time field not found in card") } - cardResolved := got["card"].(map[string]any) - headerResolved := cardResolved["header"].(map[string]any) - if headerResolved["template"] != "green" { - t.Errorf("resolved header template = %v, want green", headerResolved["template"]) + + // Verify all severities & status mappings + severityCases := []struct { + severity notify.Severity + status string + wantTpl string + }{ + {SeverityWarning, "firing", "orange"}, + {SeverityInfo, "firing", "blue"}, + {SeverityCritical, "acknowledged", "yellow"}, + {SeverityCritical, "resolved", "green"}, + } + + for _, sc := range severityCases { + err = sender.Send(context.Background(), Message{ + Subject: "状态测试", + Severity: sc.severity, + Labels: map[string]string{ + "status": sc.status, + }, + }) + if err != nil { + t.Fatalf("Send severity %v status %v: %v", sc.severity, sc.status, err) + } + c := got["card"].(map[string]any) + h := c["header"].(map[string]any) + if h["template"] != sc.wantTpl { + t.Errorf("severity=%s status=%s got template %v, want %v", sc.severity, sc.status, h["template"], sc.wantTpl) + } } } diff --git a/internal/pkg/notify/webhook.go b/internal/pkg/notify/webhook.go index 29cd121c2..b43e09474 100644 --- a/internal/pkg/notify/webhook.go +++ b/internal/pkg/notify/webhook.go @@ -161,25 +161,27 @@ func formatFeishuCard(msg Message) map[string]any { colorTemplate := "grey" prefix := "🔔【系统通知】" - isResolved := false - if status, ok := msg.Labels["status"]; ok && strings.EqualFold(status, "resolved") { - isResolved = true - } else if strings.Contains(strings.ToLower(msg.Subject), "resolved") { - isResolved = true - } + statusLower := strings.ToLower(strings.TrimSpace(msg.Labels["status"])) + isResolved := statusLower == "resolved" || strings.Contains(strings.ToLower(msg.Subject), "resolved") if isResolved { colorTemplate = "green" prefix = "✅【告警恢复】" + } else if statusLower == "acknowledged" || statusLower == "ack" { + colorTemplate = "yellow" + prefix = "🟡【告警认领】" + } else if statusLower == "silenced" { + colorTemplate = "grey" + prefix = "⚪【告警静音】" } else { - switch msg.Severity { - case SeverityCritical: + switch strings.ToLower(strings.TrimSpace(string(msg.Severity))) { + case "critical", "fatal", "emergency", "high", "p0", "p1": colorTemplate = "red" prefix = "🚨【严重告警】" - case SeverityWarning: + case "warning", "warn", "medium", "p2": colorTemplate = "orange" prefix = "⚠️【预警提示】" - case SeverityInfo: + case "info", "notice", "low", "p3": colorTemplate = "blue" prefix = "ℹ️【通知提醒】" default: @@ -225,23 +227,38 @@ func formatFeishuCard(msg Message) map[string]any { }) } - // 1. 告警级别 + // 1. 告警级别 (适配所有告警级别) + sevStr := strings.ToLower(strings.TrimSpace(string(msg.Severity))) + switch sevStr { + case "critical", "fatal", "emergency", "high", "p0", "p1": + addField("告警级别", "CRITICAL (严重)") + case "warning", "warn", "medium", "p2": + addField("告警级别", "WARNING (警告)") + case "info", "notice", "low", "p3": + addField("告警级别", "INFO (信息)") + default: + if sevStr != "" { + addField("告警级别", strings.ToUpper(sevStr)) + } + } + + // 2. 告警状态 (生命周期状态跟踪) if isResolved { addField("告警状态", "RESOLVED (已恢复)") - } else { - switch msg.Severity { - case SeverityCritical: - addField("告警级别", "CRITICAL (严重)") - case SeverityWarning: - addField("告警级别", "WARNING (警告)") - case SeverityInfo: - addField("告警级别", "INFO (信息)") + } else if statusLower != "" { + switch statusLower { + case "firing": + addField("告警状态", "FIRING (发生中)") + case "acknowledged", "ack": + addField("告警状态", "ACKNOWLEDGED (已认领)") + case "silenced": + addField("告警状态", "SILENCED (已静音)") default: - addField("告警级别", strings.ToUpper(string(msg.Severity))) + addField("告警状态", strings.ToUpper(statusLower)) } } - // 2. 告警对象 + // 3. 告警对象 if dn, ok := msg.Labels["device_name"]; ok && dn != "" { if did, ok2 := msg.Labels["device_id"]; ok2 && did != "" { addField("告警对象", fmt.Sprintf("%s (ID: %s)", dn, did)) @@ -254,7 +271,7 @@ func formatFeishuCard(msg Message) map[string]any { addField("告警服务", svc) } - // 3. 规则名称 + // 4. 规则名称 if rn, ok := msg.Labels["rule_name"]; ok && rn != "" { if rk, ok2 := msg.Labels["rule"]; ok2 && rk != "" && rk != rn { addField("规则名称", fmt.Sprintf("%s (`%s`)", rn, rk)) @@ -265,12 +282,20 @@ func formatFeishuCard(msg Message) map[string]any { addField("规则名称", fmt.Sprintf("`%s`", rk)) } - // 4. 触发时间 + // 5. 触发时间 (自动转为北京时间 CST / UTC+8,消除 Linux / Docker 容器默认 UTC 导致的 8 小时偏差) occurredAt := msg.OccurredAt if occurredAt.IsZero() { occurredAt = time.Now() } - addField("发生时间", occurredAt.Local().Format("2006-01-02 15:04:05")) + loc := time.FixedZone("CST", 8*3600) + if tzName, ok := msg.Labels["timezone"]; ok && tzName != "" { + if l, err := time.LoadLocation(tzName); err == nil { + loc = l + } + } else if l, err := time.LoadLocation("Asia/Shanghai"); err == nil { + loc = l + } + addField("发生时间", occurredAt.In(loc).Format("2006-01-02 15:04:05")) if len(fields) > 0 { elements = append(elements, map[string]any{ From c47eae69ae46aff8adf8b31ab3e9e5850e42a277 Mon Sep 17 00:00:00 2001 From: Zara1024 Date: Tue, 29 Sep 2026 12:13:05 +0800 Subject: [PATCH 4/9] fix(test): use package-local Severity type in notify_test.go --- internal/pkg/notify/notify_test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/pkg/notify/notify_test.go b/internal/pkg/notify/notify_test.go index 232d0fa13..6d11e0d8f 100644 --- a/internal/pkg/notify/notify_test.go +++ b/internal/pkg/notify/notify_test.go @@ -178,7 +178,7 @@ func TestFeishuSenderEmitsInteractiveCard(t *testing.T) { // Verify all severities & status mappings severityCases := []struct { - severity notify.Severity + severity Severity status string wantTpl string }{ From a112a2105dcd3329b5430329371ec9ec25a29e45 Mon Sep 17 00:00:00 2001 From: Zara1024 Date: Tue, 29 Sep 2026 12:14:35 +0800 Subject: [PATCH 5/9] fix(test): correct JSON array type assertion for card fields --- internal/pkg/notify/notify_test.go | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/internal/pkg/notify/notify_test.go b/internal/pkg/notify/notify_test.go index 6d11e0d8f..59a960338 100644 --- a/internal/pkg/notify/notify_test.go +++ b/internal/pkg/notify/notify_test.go @@ -160,10 +160,11 @@ func TestFeishuSenderEmitsInteractiveCard(t *testing.T) { // Verify timezone is converted to Beijing time (+8 hours from UTC) // occurred is 2026-09-29 03:30:00 UTC => 2026-09-29 11:30:00 CST - fieldsElem := elements[0].(map[string]any)["fields"].([]map[string]any) + fieldsElem := elements[0].(map[string]any)["fields"].([]any) foundTime := false for _, f := range fieldsElem { - textMap := f["text"].(map[string]any) + fMap := f.(map[string]any) + textMap := fMap["text"].(map[string]any) content := textMap["content"].(string) if strings.Contains(content, "发生时间") { foundTime = true From 95fd099a70e19034e1e095945d6e839c68482098 Mon Sep 17 00:00:00 2001 From: Zara1024 Date: Tue, 29 Sep 2026 12:15:57 +0800 Subject: [PATCH 6/9] test(notify): update expected CST time to match 10:00 UTC --- internal/pkg/notify/notify_test.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/internal/pkg/notify/notify_test.go b/internal/pkg/notify/notify_test.go index 59a960338..644da3b1c 100644 --- a/internal/pkg/notify/notify_test.go +++ b/internal/pkg/notify/notify_test.go @@ -159,7 +159,7 @@ func TestFeishuSenderEmitsInteractiveCard(t *testing.T) { } // Verify timezone is converted to Beijing time (+8 hours from UTC) - // occurred is 2026-09-29 03:30:00 UTC => 2026-09-29 11:30:00 CST + // occurred is 2026-09-29 10:00:00 UTC => 2026-09-29 18:00:00 CST fieldsElem := elements[0].(map[string]any)["fields"].([]any) foundTime := false for _, f := range fieldsElem { @@ -168,8 +168,8 @@ func TestFeishuSenderEmitsInteractiveCard(t *testing.T) { content := textMap["content"].(string) if strings.Contains(content, "发生时间") { foundTime = true - if !strings.Contains(content, "2026-09-29 11:30:00") { - t.Errorf("expected occurredAt in CST '2026-09-29 11:30:00', got %s", content) + if !strings.Contains(content, "2026-09-29 18:00:00") { + t.Errorf("expected occurredAt in CST '2026-09-29 18:00:00', got %s", content) } } } From aa54fdc296d0af7510014baf16374f7c671c2128 Mon Sep 17 00:00:00 2001 From: Zara1024 Date: Tue, 29 Sep 2026 14:23:15 +0800 Subject: [PATCH 7/9] feat(notify): upgrade dingtalk alert notifications to actioncard and markdown --- internal/pkg/notify/notify_test.go | 103 +++++++++++++++ internal/pkg/notify/webhook.go | 198 ++++++++++++++++++++++++++++- 2 files changed, 296 insertions(+), 5 deletions(-) diff --git a/internal/pkg/notify/notify_test.go b/internal/pkg/notify/notify_test.go index 644da3b1c..5f0305421 100644 --- a/internal/pkg/notify/notify_test.go +++ b/internal/pkg/notify/notify_test.go @@ -327,3 +327,106 @@ func TestSlackSenderColorByUnknownSeverity(t *testing.T) { } } } + +func TestDingTalkSenderActionCard(t *testing.T) { + var got map[string]any + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if err := json.NewDecoder(r.Body).Decode(&got); err != nil { + t.Errorf("decode payload: %v", err) + } + w.WriteHeader(http.StatusOK) + })) + defer srv.Close() + + sender := NewDingTalkSender("dingtalk", srv.URL, "secret", srv.Client()) + occurred := time.Date(2026, 5, 30, 10, 0, 0, 0, time.UTC) + err := sender.Send(context.Background(), Message{ + Subject: "edge-node-01 离线", + Severity: SeverityCritical, + OccurredAt: occurred, + Body: "Device edge-node-01 has been disconnected for more than 5 minutes", + Source: "alert-pipeline", + Labels: map[string]string{ + "rule_name": "主机离线告警", + "device_name": "edge-node-01", + "device_id": "42", + "incident_id": "101", + "console_url": "https://ongrid.example.com", + "runbook_url": "https://ongrid.example.com/wiki/runbook", + }, + }) + if err != nil { + t.Fatalf("Send: %v", err) + } + + if got["msgtype"] != "actionCard" { + t.Fatalf("expected msgtype=actionCard, got %v", got["msgtype"]) + } + + card, ok := got["actionCard"].(map[string]any) + if !ok { + t.Fatalf("actionCard is not a map: %v", got["actionCard"]) + } + + title, _ := card["title"].(string) + if !strings.Contains(title, "🚨【严重告警】主机离线告警 - edge-node-01") { + t.Errorf("expected title to contain 🚨【严重告警】主机离线告警 - edge-node-01, got %q", title) + } + + text, _ := card["text"].(string) + if !strings.Contains(text, "2026-05-30 18:00:00") { + t.Errorf("expected text to contain CST time 2026-05-30 18:00:00, got %q", text) + } + if !strings.Contains(text, "CRITICAL (严重)") { + t.Errorf("expected text to contain CRITICAL (严重), got %q", text) + } + + btns, ok := card["btns"].([]any) + if !ok || len(btns) != 3 { + t.Fatalf("expected 3 buttons, got %v", card["btns"]) + } +} + +func TestDingTalkSenderMarkdownFallback(t *testing.T) { + var got map[string]any + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if err := json.NewDecoder(r.Body).Decode(&got); err != nil { + t.Errorf("decode payload: %v", err) + } + w.WriteHeader(http.StatusOK) + })) + defer srv.Close() + + sender := NewDingTalkSender("dingtalk", srv.URL, "secret", srv.Client()) + err := sender.Send(context.Background(), Message{ + Subject: "设备恢复正常", + Severity: SeverityInfo, + Labels: map[string]string{ + "status": "resolved", + "rule_name": "主机离线告警", + }, + }) + if err != nil { + t.Fatalf("Send: %v", err) + } + + if got["msgtype"] != "markdown" { + t.Fatalf("expected msgtype=markdown when no action buttons, got %v", got["msgtype"]) + } + + md, ok := got["markdown"].(map[string]any) + if !ok { + t.Fatalf("markdown is not a map: %v", got["markdown"]) + } + + title, _ := md["title"].(string) + if !strings.Contains(title, "✅【告警恢复】主机离线告警") { + t.Errorf("expected resolved title, got %q", title) + } + + text, _ := md["text"].(string) + if !strings.Contains(text, "RESOLVED (已恢复)") { + t.Errorf("expected text to contain RESOLVED (已恢复), got %q", text) + } +} + diff --git a/internal/pkg/notify/webhook.go b/internal/pkg/notify/webhook.go index b43e09474..3b7126de6 100644 --- a/internal/pkg/notify/webhook.go +++ b/internal/pkg/notify/webhook.go @@ -390,16 +390,204 @@ func formatFeishuCard(msg Message) map[string]any { } -// NewDingTalkSender posts a text payload compatible with DingTalk custom bots. +// NewDingTalkSender posts an ActionCard or Markdown payload compatible with DingTalk custom bots. func NewDingTalkSender(name, endpoint, secret string, client *http.Client) Sender { return newWebhookSender(name, endpoint, secret, client, func(msg Message) (any, error) { - return map[string]any{ - "msgtype": "text", - "text": map[string]string{"content": formatText(msg)}, - }, nil + return formatDingTalkMessage(msg), nil }, signDingTalkURL) } +// formatDingTalkMessage renders a Message as a DingTalk ActionCard or Markdown message. +// When action buttons are available, it uses msgtype "actionCard" with horizontal buttons; +// otherwise it falls back to msgtype "markdown". It supports Chinese alert titles, +// colored severity/status tags, CST time conversion, structured fields, and runbook links. +func formatDingTalkMessage(msg Message) map[string]any { + prefix := "🔔【系统通知】" + + statusLower := strings.ToLower(strings.TrimSpace(msg.Labels["status"])) + isResolved := statusLower == "resolved" || strings.Contains(strings.ToLower(msg.Subject), "resolved") + + var sevColor, statusColor, sevText, statusText string + + if isResolved { + prefix = "✅【告警恢复】" + statusColor = "#00AA00" + statusText = "RESOLVED (已恢复)" + } else if statusLower == "acknowledged" || statusLower == "ack" { + prefix = "🟡【告警认领】" + statusColor = "#FFB800" + statusText = "ACKNOWLEDGED (已认领)" + } else if statusLower == "silenced" { + prefix = "⚪【告警静音】" + statusColor = "#888888" + statusText = "SILENCED (已静音)" + } else { + switch strings.ToLower(strings.TrimSpace(string(msg.Severity))) { + case "critical", "fatal", "emergency", "high", "p0", "p1": + prefix = "🚨【严重告警】" + sevColor = "#EA3323" + sevText = "CRITICAL (严重)" + case "warning", "warn", "medium", "p2": + prefix = "⚠️【预警提示】" + sevColor = "#FF7700" + sevText = "WARNING (警告)" + case "info", "notice", "low", "p3": + prefix = "ℹ️【通知提醒】" + sevColor = "#1E80FF" + sevText = "INFO (信息)" + default: + prefix = "🔔【系统通知】" + sevColor = "#888888" + if s := strings.TrimSpace(string(msg.Severity)); s != "" { + sevText = strings.ToUpper(s) + } + } + + if statusLower == "firing" { + statusColor = "#EA3323" + statusText = "FIRING (发生中)" + } else if statusLower != "" { + statusColor = "#888888" + statusText = strings.ToUpper(statusLower) + } + } + + ruleTitle := msg.Subject + if rn, ok := msg.Labels["rule_name"]; ok && rn != "" { + if dn, ok2 := msg.Labels["device_name"]; ok2 && dn != "" { + ruleTitle = fmt.Sprintf("%s - %s", rn, dn) + } else { + ruleTitle = rn + } + } + if ruleTitle == "" { + ruleTitle = "告警事件通知" + } + fullTitle := prefix + ruleTitle + + // Construct markdown content + var sb strings.Builder + sb.WriteString(fmt.Sprintf("### %s\n\n---\n", fullTitle)) + + if sevText != "" { + sb.WriteString(fmt.Sprintf("- **告警级别**:%s\n", sevColor, sevText)) + } + if statusText != "" { + sb.WriteString(fmt.Sprintf("- **告警状态**:%s\n", statusColor, statusText)) + } + + // 告警对象 + if dn, ok := msg.Labels["device_name"]; ok && dn != "" { + if did, ok2 := msg.Labels["device_id"]; ok2 && did != "" { + sb.WriteString(fmt.Sprintf("- **告警对象**:%s (ID: %s)\n", dn, did)) + } else { + sb.WriteString(fmt.Sprintf("- **告警对象**:%s\n", dn)) + } + } else if did, ok := msg.Labels["device_id"]; ok && did != "" { + sb.WriteString(fmt.Sprintf("- **告警对象**:设备 #%s\n", did)) + } else if svc, ok := msg.Labels["service"]; ok && svc != "" { + sb.WriteString(fmt.Sprintf("- **告警服务**:%s\n", svc)) + } + + // 规则名称 + if rn, ok := msg.Labels["rule_name"]; ok && rn != "" { + if rk, ok2 := msg.Labels["rule"]; ok2 && rk != "" && rk != rn { + sb.WriteString(fmt.Sprintf("- **规则名称**:%s (`%s`)\n", rn, rk)) + } else { + sb.WriteString(fmt.Sprintf("- **规则名称**:%s\n", rn)) + } + } else if rk, ok := msg.Labels["rule"]; ok && rk != "" { + sb.WriteString(fmt.Sprintf("- **规则名称**:`%s`\n", rk)) + } + + // 时间转换 (北京时间 CST / UTC+8) + occurredAt := msg.OccurredAt + if occurredAt.IsZero() { + occurredAt = time.Now() + } + loc := time.FixedZone("CST", 8*3600) + if tzName, ok := msg.Labels["timezone"]; ok && tzName != "" { + if l, err := time.LoadLocation(tzName); err == nil { + loc = l + } + } else if l, err := time.LoadLocation("Asia/Shanghai"); err == nil { + loc = l + } + timeLabel := "发生时间" + if isResolved { + timeLabel = "恢复时间" + } + sb.WriteString(fmt.Sprintf("- **%s**:%s\n", timeLabel, occurredAt.In(loc).Format("2006-01-02 15:04:05"))) + + // 详细描述 / 触发条件 + if msg.Body != "" { + sb.WriteString(fmt.Sprintf("- **告警详情**:%s\n", msg.Body)) + } else if expr, ok := msg.Labels["rule_expr"]; ok && expr != "" { + sb.WriteString(fmt.Sprintf("- **触发条件**:`%s`\n", expr)) + if msg.Subject != "" && msg.Subject != ruleTitle { + sb.WriteString(fmt.Sprintf("- **告警详情**:%s\n", msg.Subject)) + } + } else if msg.Subject != "" && msg.Subject != ruleTitle { + sb.WriteString(fmt.Sprintf("- **告警详情**:%s\n", msg.Subject)) + } + + // 运维手册 + if rb, ok := msg.Labels["runbook_url"]; ok && rb != "" { + sb.WriteString(fmt.Sprintf("- **运维手册**:[点击查看处理指引](%s)\n", rb)) + } + + // 来源落款 + source := msg.Source + if source == "" { + source = "global" + } + sb.WriteString(fmt.Sprintf("\n> 推送来源:Ongrid AIOps 智能运维平台 | 来源: %s", source)) + + // 操作按钮 (btns) + baseURL := strings.TrimRight(strings.TrimSpace(msg.Labels["console_url"]), "/") + btns := make([]map[string]string, 0, 3) + if baseURL != "" { + if did, ok := msg.Labels["device_id"]; ok && did != "" { + btns = append(btns, map[string]string{ + "title": "🔍 查看设备", + "actionURL": fmt.Sprintf("%s/devices/%s", baseURL, did), + }) + } + if iid, ok := msg.Labels["incident_id"]; ok && iid != "" { + btns = append(btns, map[string]string{ + "title": "📋 告警事件", + "actionURL": fmt.Sprintf("%s/alerts", baseURL), + }) + } + } + if rb, ok := msg.Labels["runbook_url"]; ok && rb != "" && len(btns) > 0 && len(btns) < 3 { + btns = append(btns, map[string]string{ + "title": "📖 运维手册", + "actionURL": rb, + }) + } + + if len(btns) > 0 { + return map[string]any{ + "msgtype": "actionCard", + "actionCard": map[string]any{ + "title": fullTitle, + "text": sb.String(), + "btnOrientation": "1", + "btns": btns, + }, + } + } + + return map[string]any{ + "msgtype": "markdown", + "markdown": map[string]any{ + "title": fullTitle, + "text": sb.String(), + }, + } +} + // NewWeComSender posts a text payload compatible with 企业微信 (WeCom) group // bots. Endpoint URL carries the bot key as a query param; the v1 wiring // has no extra signing — the secret query string IS the credential. Same From 15c05ab1de1c092954485e37ba746e81081b44b3 Mon Sep 17 00:00:00 2001 From: Zara1024 <101550744+Zara1024@users.noreply.github.com> Date: Wed, 30 Sep 2026 11:02:29 +0800 Subject: [PATCH 8/9] fix(notify): address card review fixes for feishu and dingtalk state/console URL Use structured incident status only (no subject substring), localize open/firing as OPEN, inject PublicURL as console_url in notify/retry, and deep-link DingTalk/Feishu buttons to /alerts/incidents/{id}. Co-authored-by: Cursor --- cmd/ongrid/main.go | 2 + internal/manager/biz/alert/pipeline.go | 9 ++ internal/manager/biz/alert/pipeline_test.go | 24 ++++ internal/manager/biz/alert/retry.go | 24 +++- internal/pkg/notify/notify_test.go | 124 ++++++++++++++++++++ internal/pkg/notify/webhook.go | 45 +++++-- 6 files changed, 217 insertions(+), 11 deletions(-) diff --git a/cmd/ongrid/main.go b/cmd/ongrid/main.go index 8d175538b..709530460 100644 --- a/cmd/ongrid/main.go +++ b/cmd/ongrid/main.go @@ -2940,6 +2940,7 @@ func main() { PromQuerier: alertPromQuerier, LogQuerier: lokiLogClient, LogSearcher: logsBackendSvc, + ConsoleURL: cfg.PublicURL, DeviceIdentityResolver: func(ctx context.Context, deviceID uint64) (managerbizalert.DeviceIdentity, error) { device, err := deviceUC.Get(ctx, deviceID) if err != nil { @@ -2964,6 +2965,7 @@ func main() { Usecase: alertUC, MaxAttempts: 5, Tick: cfg.Alert.EvaluatorInterval, + ConsoleURL: cfg.PublicURL, Log: log.With(slog.String("comp", "alert-retry")), }) eg.Go(func() error { return retryWorker.Loop(egCtx) }) diff --git a/internal/manager/biz/alert/pipeline.go b/internal/manager/biz/alert/pipeline.go index 3c2099dce..e0cb59992 100644 --- a/internal/manager/biz/alert/pipeline.go +++ b/internal/manager/biz/alert/pipeline.go @@ -91,6 +91,10 @@ type PipelineEvaluatorOpts struct { DeviceIdentityResolver DeviceIdentityResolver + // ConsoleURL is the public console base used for notification card deep links + // (devices / incidents). Empty disables action buttons. + ConsoleURL string + Log *slog.Logger Now func() time.Time } @@ -117,6 +121,7 @@ type PipelineEvaluator struct { logq LogQuerier logSearcher logquery.Searcher deviceIdentity DeviceIdentityResolver + consoleURL string // gaugeSnapshot is the previous tick's (device_id, device_name) set // used by refreshDeviceStalenessGauge to garbage-collect series for @@ -166,6 +171,7 @@ func NewPipelineEvaluator(opts PipelineEvaluatorOpts) *PipelineEvaluator { logq: opts.LogQuerier, logSearcher: opts.LogSearcher, deviceIdentity: opts.DeviceIdentityResolver, + consoleURL: strings.TrimRight(strings.TrimSpace(opts.ConsoleURL), "/"), log: opts.Log, now: opts.Now, } @@ -431,6 +437,9 @@ func (e *PipelineEvaluator) notify(ctx context.Context, res *FiringResult, summa if res.Incident.RunbookURL != "" { msg.Labels["runbook_url"] = res.Incident.RunbookURL } + if e.consoleURL != "" { + msg.Labels["console_url"] = e.consoleURL + } // Preserve application identity for notification routing and correlation. // Control labels such as incident_id/rule still come from the incident. if labels, err := res.Incident.Labels(); err != nil { diff --git a/internal/manager/biz/alert/pipeline_test.go b/internal/manager/biz/alert/pipeline_test.go index 2c0c9c21c..611640617 100644 --- a/internal/manager/biz/alert/pipeline_test.go +++ b/internal/manager/biz/alert/pipeline_test.go @@ -39,6 +39,30 @@ func TestPipelineNotificationPreservesApplicationIdentity(t *testing.T) { } } +func TestPipelineNotificationInjectsConsoleURL(t *testing.T) { + repo, notifier := newFakeRepo(), &fakeNotifier{} + prom := &fakePromQuerier{result: &promquery.InstantResult{ResultType: "vector", Result: json.RawMessage(`[{"metric":{"device_id":"9"},"value":[1,"1"]}]`)}} + rules := NewStaticRulesProvider(WithMetricRawRules([]MetricRawRule{{ID: 1, RuleKey: "cpu_high", Name: "CPU high", ScopeType: "global", Expr: "up > 0", Severity: "critical"}})) + evaluator := newPipelineEvaluator(t, repo, notifier, rules, PipelineEvaluatorOpts{ + PromQuerier: prom, + ConsoleURL: "https://ongrid.example.com/", + }) + evaluator.EvaluateOnce(t.Context()) + if len(notifier.msgs) != 1 { + t.Fatalf("notifications=%d", len(notifier.msgs)) + } + labels := notifier.msgs[0].Labels + if labels["console_url"] != "https://ongrid.example.com" { + t.Fatalf("console_url = %q, want trimmed public URL", labels["console_url"]) + } + if labels["status"] != model.IncidentStatusOpen { + t.Fatalf("status = %q, want %q", labels["status"], model.IncidentStatusOpen) + } + if labels["rule_name"] != "CPU high" { + t.Fatalf("rule_name = %q", labels["rule_name"]) + } +} + func (f *fakeEdgeLister) List(_ context.Context, _ edgebiz.ListFilter) ([]*edgemodel.Edge, error) { return f.edges, f.err } diff --git a/internal/manager/biz/alert/retry.go b/internal/manager/biz/alert/retry.go index abd6a2168..76eb94f99 100644 --- a/internal/manager/biz/alert/retry.go +++ b/internal/manager/biz/alert/retry.go @@ -4,6 +4,7 @@ import ( "context" "fmt" "log/slog" + "strings" "time" model "github.com/ongridio/ongrid/internal/manager/model/alert" @@ -23,8 +24,11 @@ type RetryWorkerOpts struct { MaxAttempts uint32 BackoffPerAttempt time.Duration Tick time.Duration - Log *slog.Logger - Now func() time.Time + // ConsoleURL is the public console base injected into retry payloads so + // Feishu cards retain the same deep-link buttons as first-send notifies. + ConsoleURL string + Log *slog.Logger + Now func() time.Time } // RetryWorker drains failed notification_deliveries, re-runs the underlying @@ -38,6 +42,7 @@ type RetryWorker struct { maxAttempts uint32 backoffPerAttempt time.Duration tick time.Duration + consoleURL string log *slog.Logger now func() time.Time } @@ -67,6 +72,7 @@ func NewRetryWorker(opts RetryWorkerOpts) *RetryWorker { maxAttempts: opts.MaxAttempts, backoffPerAttempt: opts.BackoffPerAttempt, tick: opts.Tick, + consoleURL: strings.TrimRight(strings.TrimSpace(opts.ConsoleURL), "/"), log: opts.Log, now: opts.Now, } @@ -158,7 +164,7 @@ func (w *RetryWorker) retryOne(ctx context.Context, d *model.Delivery, now time. return } - msg := buildIncidentMessage(incident, now) + msg := buildIncidentMessage(incident, now, w.consoleURL) sentAt := now finished := now sendErr := w.notifier.Send(ctx, msg, channel.Name) @@ -200,15 +206,25 @@ func (w *RetryWorker) retryOne(ctx context.Context, d *model.Delivery, now time. } } -func buildIncidentMessage(incident *model.Incident, now time.Time) notify.Message { +func buildIncidentMessage(incident *model.Incident, now time.Time, consoleURL string) notify.Message { severity := notify.Severity(incident.Severity) if severity == "" { severity = notify.SeverityWarning } labels := map[string]string{ "rule": incident.Rule, + "status": incident.Status, "incident_id": fmt.Sprintf("%d", incident.ID), } + if incident.RuleName != "" { + labels["rule_name"] = incident.RuleName + } + if incident.RunbookURL != "" { + labels["runbook_url"] = incident.RunbookURL + } + if consoleURL != "" { + labels["console_url"] = consoleURL + } if incident.DeviceID != nil { labels["device_id"] = fmt.Sprintf("%d", *incident.DeviceID) } diff --git a/internal/pkg/notify/notify_test.go b/internal/pkg/notify/notify_test.go index 5f0305421..60ebba609 100644 --- a/internal/pkg/notify/notify_test.go +++ b/internal/pkg/notify/notify_test.go @@ -184,6 +184,7 @@ func TestFeishuSenderEmitsInteractiveCard(t *testing.T) { wantTpl string }{ {SeverityWarning, "firing", "orange"}, + {SeverityWarning, "open", "orange"}, {SeverityInfo, "firing", "blue"}, {SeverityCritical, "acknowledged", "yellow"}, {SeverityCritical, "resolved", "green"}, @@ -208,6 +209,56 @@ func TestFeishuSenderEmitsInteractiveCard(t *testing.T) { } } +func TestFeishuCardIgnoresResolvedSubstringInSubject(t *testing.T) { + card := formatFeishuCard(Message{ + Subject: "unresolved errors above threshold on systemd-resolved", + Severity: SeverityCritical, + Labels: map[string]string{ + "status": "open", + "incident_id": "42", + "console_url": "https://console.example", + "device_id": "7", + }, + }) + + header, ok := card["header"].(map[string]any) + if !ok { + t.Fatalf("header missing: %#v", card["header"]) + } + if header["template"] != "red" { + t.Fatalf("template = %v, want red (open critical must not be treated as recovery)", header["template"]) + } + + raw, err := json.Marshal(card) + if err != nil { + t.Fatalf("marshal card: %v", err) + } + payload := string(raw) + if !strings.Contains(payload, "OPEN (发生中)") { + t.Errorf("expected OPEN status localization in card, got %s", payload) + } + if strings.Contains(payload, "RESOLVED (已恢复)") { + t.Errorf("status must not be marked resolved when Labels[status]=open: %s", payload) + } + if !strings.Contains(payload, "#42") || !strings.Contains(payload, "事件 ID") { + t.Errorf("expected visible incident ID #42 in card: %s", payload) + } + if !strings.Contains(payload, "https://console.example/alerts/incidents/42") { + t.Errorf("expected incident deep link /alerts/incidents/42 in card: %s", payload) + } +} + +func TestFeishuCardResolvedOnlyFromStatusLabel(t *testing.T) { + card := formatFeishuCard(Message{ + Subject: "CPU high", + Severity: SeverityCritical, + Labels: map[string]string{"status": "resolved"}, + }) + header := card["header"].(map[string]any) + if header["template"] != "green" { + t.Fatalf("resolved status template = %v, want green", header["template"]) + } +} func TestDingTalkSenderSignsURL(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { @@ -347,6 +398,7 @@ func TestDingTalkSenderActionCard(t *testing.T) { Body: "Device edge-node-01 has been disconnected for more than 5 minutes", Source: "alert-pipeline", Labels: map[string]string{ + "status": "open", "rule_name": "主机离线告警", "device_name": "edge-node-01", "device_id": "42", @@ -380,11 +432,83 @@ func TestDingTalkSenderActionCard(t *testing.T) { if !strings.Contains(text, "CRITICAL (严重)") { t.Errorf("expected text to contain CRITICAL (严重), got %q", text) } + if !strings.Contains(text, "OPEN (发生中)") { + t.Errorf("expected text to contain OPEN (发生中), got %q", text) + } + if !strings.Contains(text, "#101") { + t.Errorf("expected visible incident id #101 in text, got %q", text) + } btns, ok := card["btns"].([]any) if !ok || len(btns) != 3 { t.Fatalf("expected 3 buttons, got %v", card["btns"]) } + foundIncidentDeepLink := false + for _, raw := range btns { + btn, _ := raw.(map[string]any) + if btn["actionURL"] == "https://ongrid.example.com/alerts/incidents/101" { + foundIncidentDeepLink = true + } + } + if !foundIncidentDeepLink { + t.Fatalf("expected incident deep link button, got %#v", btns) + } +} + +func TestDingTalkCardIgnoresResolvedSubstringInSubject(t *testing.T) { + payload := formatDingTalkMessage(Message{ + Subject: "unresolved errors above threshold on systemd-resolved", + Severity: SeverityCritical, + Labels: map[string]string{ + "status": "open", + "incident_id": "42", + "console_url": "https://console.example", + "device_id": "7", + }, + }) + if payload["msgtype"] != "actionCard" { + t.Fatalf("msgtype = %v, want actionCard", payload["msgtype"]) + } + card := payload["actionCard"].(map[string]any) + title, _ := card["title"].(string) + if strings.Contains(title, "告警恢复") { + t.Fatalf("title must not be recovery for open critical: %q", title) + } + if !strings.Contains(title, "严重告警") { + t.Fatalf("title = %q, want critical alert prefix", title) + } + text, _ := card["text"].(string) + if !strings.Contains(text, "OPEN (发生中)") { + t.Errorf("expected OPEN status localization, got %q", text) + } + if strings.Contains(text, "RESOLVED") { + t.Errorf("must not mark resolved when Labels[status]=open: %q", text) + } + if !strings.Contains(text, "#42") { + t.Errorf("expected visible incident id, got %q", text) + } + + found := false + switch btns := card["btns"].(type) { + case []map[string]string: + for _, btn := range btns { + if btn["actionURL"] == "https://console.example/alerts/incidents/42" { + found = true + } + } + case []any: + for _, raw := range btns { + btn, _ := raw.(map[string]any) + if btn["actionURL"] == "https://console.example/alerts/incidents/42" { + found = true + } + } + default: + t.Fatalf("unexpected btns type %T: %#v", card["btns"], card["btns"]) + } + if !found { + t.Fatalf("expected incident deep link, got %#v", card["btns"]) + } } func TestDingTalkSenderMarkdownFallback(t *testing.T) { diff --git a/internal/pkg/notify/webhook.go b/internal/pkg/notify/webhook.go index 3b7126de6..ec4e03601 100644 --- a/internal/pkg/notify/webhook.go +++ b/internal/pkg/notify/webhook.go @@ -161,8 +161,12 @@ func formatFeishuCard(msg Message) map[string]any { colorTemplate := "grey" prefix := "🔔【系统通知】" + // Recovery / lifecycle presentation must follow structured status labels + // only. Never infer "resolved" from subject text — subjects can contain + // substrings like "unresolved" or service names like "systemd-resolved". statusLower := strings.ToLower(strings.TrimSpace(msg.Labels["status"])) - isResolved := statusLower == "resolved" || strings.Contains(strings.ToLower(msg.Subject), "resolved") + isResolved := statusLower == "resolved" + isActive := statusLower == "" || statusLower == "open" || statusLower == "firing" if isResolved { colorTemplate = "green" @@ -243,12 +247,14 @@ func formatFeishuCard(msg Message) map[string]any { } // 2. 告警状态 (生命周期状态跟踪) + // Domain active status is "open" (see alert model); "firing" is the event + // type / legacy label. Localize both as an active incident. if isResolved { addField("告警状态", "RESOLVED (已恢复)") + } else if isActive { + addField("告警状态", "OPEN (发生中)") } else if statusLower != "" { switch statusLower { - case "firing": - addField("告警状态", "FIRING (发生中)") case "acknowledged", "ack": addField("告警状态", "ACKNOWLEDGED (已认领)") case "silenced": @@ -258,6 +264,11 @@ func formatFeishuCard(msg Message) map[string]any { } } + // Incident identity — keep a concise visible reference (not the long dedupe key). + if iid, ok := msg.Labels["incident_id"]; ok && iid != "" { + addField("事件 ID", "#"+iid) + } + // 3. 告警对象 if dn, ok := msg.Labels["device_name"]; ok && dn != "" { if did, ok2 := msg.Labels["device_id"]; ok2 && did != "" { @@ -348,6 +359,13 @@ func formatFeishuCard(msg Message) map[string]any { }) } if iid, ok := msg.Labels["incident_id"]; ok && iid != "" { + actions = append(actions, map[string]any{ + "tag": "button", + "text": map[string]any{"tag": "plain_text", "content": "📋 告警事件"}, + "type": "default", + "url": fmt.Sprintf("%s/alerts/incidents/%s", baseURL, iid), + }) + } else { actions = append(actions, map[string]any{ "tag": "button", "text": map[string]any{"tag": "plain_text", "content": "📋 告警事件"}, @@ -389,7 +407,6 @@ func formatFeishuCard(msg Message) map[string]any { } } - // NewDingTalkSender posts an ActionCard or Markdown payload compatible with DingTalk custom bots. func NewDingTalkSender(name, endpoint, secret string, client *http.Client) Sender { return newWebhookSender(name, endpoint, secret, client, func(msg Message) (any, error) { @@ -404,8 +421,12 @@ func NewDingTalkSender(name, endpoint, secret string, client *http.Client) Sende func formatDingTalkMessage(msg Message) map[string]any { prefix := "🔔【系统通知】" + // Recovery / lifecycle presentation must follow structured status labels + // only. Never infer "resolved" from subject text — subjects can contain + // substrings like "unresolved" or service names like "systemd-resolved". statusLower := strings.ToLower(strings.TrimSpace(msg.Labels["status"])) - isResolved := statusLower == "resolved" || strings.Contains(strings.ToLower(msg.Subject), "resolved") + isResolved := statusLower == "resolved" + isActive := statusLower == "" || statusLower == "open" || statusLower == "firing" var sevColor, statusColor, sevText, statusText string @@ -443,9 +464,10 @@ func formatDingTalkMessage(msg Message) map[string]any { } } - if statusLower == "firing" { + // Domain active status is "open"; "firing" is legacy / event-type label. + if isActive { statusColor = "#EA3323" - statusText = "FIRING (发生中)" + statusText = "OPEN (发生中)" } else if statusLower != "" { statusColor = "#888888" statusText = strings.ToUpper(statusLower) @@ -476,6 +498,10 @@ func formatDingTalkMessage(msg Message) map[string]any { sb.WriteString(fmt.Sprintf("- **告警状态**:%s\n", statusColor, statusText)) } + if iid, ok := msg.Labels["incident_id"]; ok && iid != "" { + sb.WriteString(fmt.Sprintf("- **事件 ID**:#%s\n", iid)) + } + // 告警对象 if dn, ok := msg.Labels["device_name"]; ok && dn != "" { if did, ok2 := msg.Labels["device_id"]; ok2 && did != "" { @@ -554,6 +580,11 @@ func formatDingTalkMessage(msg Message) map[string]any { }) } if iid, ok := msg.Labels["incident_id"]; ok && iid != "" { + btns = append(btns, map[string]string{ + "title": "📋 告警事件", + "actionURL": fmt.Sprintf("%s/alerts/incidents/%s", baseURL, iid), + }) + } else { btns = append(btns, map[string]string{ "title": "📋 告警事件", "actionURL": fmt.Sprintf("%s/alerts", baseURL), From 2cce505206500be20a45a3185e5f314ac5a45f07 Mon Sep 17 00:00:00 2001 From: Zara1024 <101550744+Zara1024@users.noreply.github.com> Date: Sun, 4 Oct 2026 17:12:25 +0800 Subject: [PATCH 9/9] fix(notify): preserve status and severity rendering --- internal/pkg/notify/notify_test.go | 23 +++++++++++++++++++++++ internal/pkg/notify/webhook.go | 28 +++++++++++++++------------- 2 files changed, 38 insertions(+), 13 deletions(-) diff --git a/internal/pkg/notify/notify_test.go b/internal/pkg/notify/notify_test.go index 60ebba609..8beb2ed4f 100644 --- a/internal/pkg/notify/notify_test.go +++ b/internal/pkg/notify/notify_test.go @@ -554,3 +554,26 @@ func TestDingTalkSenderMarkdownFallback(t *testing.T) { } } +func TestDingTalkCardOmitsOpenForOrdinaryNotification(t *testing.T) { + payload := formatDingTalkMessage(Message{Subject: "Daily report", Severity: SeverityInfo}) + card := payload["markdown"].(map[string]any) + text := card["text"].(string) + if strings.Contains(text, "OPEN") || !strings.Contains(text, "INFO (信息)") { + t.Fatalf("ordinary notification rendering = %q", text) + } +} + +func TestDingTalkCardKeepsSeverityForLifecycleStatuses(t *testing.T) { + for _, status := range []string{"acknowledged", "resolved", "silenced"} { + payload := formatDingTalkMessage(Message{Subject: "CPU high", Severity: SeverityCritical, Labels: map[string]string{"status": status}}) + var card map[string]any + if payload["msgtype"] == "actionCard" { + card = payload["actionCard"].(map[string]any) + } else { + card = payload["markdown"].(map[string]any) + } + if !strings.Contains(card["text"].(string), "CRITICAL (严重)") { + t.Errorf("status %q lost severity: %q", status, card["text"]) + } + } +} diff --git a/internal/pkg/notify/webhook.go b/internal/pkg/notify/webhook.go index ec4e03601..93e7a74c9 100644 --- a/internal/pkg/notify/webhook.go +++ b/internal/pkg/notify/webhook.go @@ -166,7 +166,7 @@ func formatFeishuCard(msg Message) map[string]any { // substrings like "unresolved" or service names like "systemd-resolved". statusLower := strings.ToLower(strings.TrimSpace(msg.Labels["status"])) isResolved := statusLower == "resolved" - isActive := statusLower == "" || statusLower == "open" || statusLower == "firing" + isActive := statusLower == "open" || statusLower == "firing" if isResolved { colorTemplate = "green" @@ -426,10 +426,23 @@ func formatDingTalkMessage(msg Message) map[string]any { // substrings like "unresolved" or service names like "systemd-resolved". statusLower := strings.ToLower(strings.TrimSpace(msg.Labels["status"])) isResolved := statusLower == "resolved" - isActive := statusLower == "" || statusLower == "open" || statusLower == "firing" + isActive := statusLower == "open" || statusLower == "firing" var sevColor, statusColor, sevText, statusText string + switch strings.ToLower(strings.TrimSpace(string(msg.Severity))) { + case "critical", "fatal", "emergency", "high", "p0", "p1": + sevColor, sevText = "#EA3323", "CRITICAL (严重)" + case "warning", "warn", "medium", "p2": + sevColor, sevText = "#FF7700", "WARNING (警告)" + case "info", "notice", "low", "p3": + sevColor, sevText = "#1E80FF", "INFO (信息)" + default: + if s := strings.TrimSpace(string(msg.Severity)); s != "" { + sevColor, sevText = "#888888", strings.ToUpper(s) + } + } + if isResolved { prefix = "✅【告警恢复】" statusColor = "#00AA00" @@ -446,24 +459,13 @@ func formatDingTalkMessage(msg Message) map[string]any { switch strings.ToLower(strings.TrimSpace(string(msg.Severity))) { case "critical", "fatal", "emergency", "high", "p0", "p1": prefix = "🚨【严重告警】" - sevColor = "#EA3323" - sevText = "CRITICAL (严重)" case "warning", "warn", "medium", "p2": prefix = "⚠️【预警提示】" - sevColor = "#FF7700" - sevText = "WARNING (警告)" case "info", "notice", "low", "p3": prefix = "ℹ️【通知提醒】" - sevColor = "#1E80FF" - sevText = "INFO (信息)" default: prefix = "🔔【系统通知】" - sevColor = "#888888" - if s := strings.TrimSpace(string(msg.Severity)); s != "" { - sevText = strings.ToUpper(s) - } } - // Domain active status is "open"; "firing" is legacy / event-type label. if isActive { statusColor = "#EA3323"