From 45f18ded00587a6e32ffe4068dac35bc5a9d4889 Mon Sep 17 00:00:00 2001 From: Amir Husayn Panahifar Date: Sun, 11 Oct 2026 12:04:41 +0330 Subject: [PATCH] feat(alert): summarize Grafana and Alertmanager payloads Extract a readable title and severity from webhook bodies and keep the raw JSON available for the raw view. --- internal/alert/alert.go | 139 +++++++++++++++++++++++++++++++++++ internal/alert/alert_test.go | 61 +++++++++++++++ 2 files changed, 200 insertions(+) create mode 100644 internal/alert/alert.go create mode 100644 internal/alert/alert_test.go diff --git a/internal/alert/alert.go b/internal/alert/alert.go new file mode 100644 index 0000000..050f076 --- /dev/null +++ b/internal/alert/alert.go @@ -0,0 +1,139 @@ +// Package alert extracts a readable title and severity from Grafana and +// Alertmanager webhook bodies without hiding the raw payload. +package alert + +import ( + "encoding/json" + "strings" +) + +// TitleSeverity returns the list title and a severity/status string. +// An ntfy title wins; otherwise Grafana's title or alertname is used. +func TitleSeverity(ntfyTitle, body string) (string, string) { + title := strings.TrimSpace(ntfyTitle) + obj, ok := object(body) + sev := "" + if ok { + labels, _ := obj["commonLabels"].(map[string]any) + if title == "" { + title = strings.TrimSpace(str(obj["title"])) + if title == "" { + title = strings.TrimSpace(str(labels["alertname"])) + } + } + sev = strings.TrimSpace(str(obj["status"])) + if sev == "" { + sev = strings.TrimSpace(str(labels["severity"])) + } + } + if title == "" { + body = strings.TrimSpace(body) + if body == "" { + title = "(empty)" + } else { + title = strings.Split(body, "\n")[0] + } + } + return title, sev +} + +// Tone picks a color role. An explicit severity wins over ntfy priority. +func Tone(sev string, priority int) string { + s := strings.ToLower(sev) + switch { + case s == "firing" || s == "critical" || s == "error" || strings.Contains(s, "critical") || strings.Contains(s, "error"): + return "bad" + case s == "resolved" || s == "ok" || s == "success" || s == "info" || strings.Contains(s, "resolved"): + return "ok" + case s != "": + return "warn" + case priority >= 5: + return "bad" + case priority == 4: + return "warn" + case priority > 0 && priority < 3: + return "dim" + default: + return "base" + } +} + +// BodyView renders an alert webhook as text, or pretty JSON for other objects. +// raw forces pretty JSON when the body is JSON. +func BodyView(body string, raw bool) string { + obj, ok := object(body) + if !ok { + return body + } + if !raw && isAlert(obj) { + return readable(obj) + } + enc, err := json.MarshalIndent(obj, "", " ") + if err != nil { + return body + } + return string(enc) +} + +func object(body string) (map[string]any, bool) { + var v any + if err := json.Unmarshal([]byte(body), &v); err != nil { + return nil, false + } + obj, ok := v.(map[string]any) + return obj, ok +} + +func isAlert(obj map[string]any) bool { + _, labels := obj["commonLabels"] + _, alerts := obj["alerts"] + _, ann := obj["commonAnnotations"] + return labels || alerts || ann +} + +func readable(obj map[string]any) string { + labels, _ := obj["commonLabels"].(map[string]any) + ann, _ := obj["commonAnnotations"].(map[string]any) + var b strings.Builder + writeKV(&b, "status", str(obj["status"])) + writeKV(&b, "alert", str(labels["alertname"])) + writeKV(&b, "severity", str(labels["severity"])) + writeKV(&b, "summary", str(ann["summary"])) + writeKV(&b, "description", str(ann["description"])) + alerts, _ := obj["alerts"].([]any) + if len(alerts) > 0 { + if b.Len() > 0 { + b.WriteByte('\n') + } + b.WriteString("alerts:\n") + for _, item := range alerts { + alert, _ := item.(map[string]any) + al, _ := alert["labels"].(map[string]any) + aa, _ := alert["annotations"].(map[string]any) + name := str(al["alertname"]) + if name == "" { + name = "(alert)" + } + b.WriteString("- [" + str(alert["status"]) + "] " + name + "\n") + if s := str(aa["summary"]); s != "" { + b.WriteString(" summary: " + s + "\n") + } + if s := str(aa["description"]); s != "" { + b.WriteString(" description: " + s + "\n") + } + } + } + return strings.TrimRight(b.String(), "\n") +} + +func writeKV(b *strings.Builder, k, v string) { + if v == "" { + return + } + b.WriteString(k + ": " + v + "\n") +} + +func str(v any) string { + s, _ := v.(string) + return s +} diff --git a/internal/alert/alert_test.go b/internal/alert/alert_test.go new file mode 100644 index 0000000..4fdef6f --- /dev/null +++ b/internal/alert/alert_test.go @@ -0,0 +1,61 @@ +package alert + +import ( + "strings" + "testing" +) + +const grafana = `{ + "status": "firing", + "title": "DiskFull", + "commonLabels": {"alertname": "DiskFull", "severity": "critical"}, + "commonAnnotations": {"summary": "disk > 90", "description": "host a"}, + "alerts": [ + {"status": "firing", "labels": {"alertname": "DiskFull", "severity": "critical"}, "annotations": {"summary": "disk > 90"}} + ] +}` + +func TestTitleSeverityPrefersNtfyTitle(t *testing.T) { + title, sev := TitleSeverity("from ntfy", grafana) + if title != "from ntfy" || sev != "firing" { + t.Fatalf("%q %q", title, sev) + } + title, sev = TitleSeverity("", grafana) + if title != "DiskFull" || sev != "firing" { + t.Fatalf("%q %q", title, sev) + } + title, sev = TitleSeverity("", "plain body") + if title != "plain body" || sev != "" { + t.Fatalf("%q %q", title, sev) + } +} + +func TestTone(t *testing.T) { + if Tone("firing", 3) != "bad" || Tone("resolved", 5) != "ok" || Tone("", 5) != "bad" || Tone("", 4) != "warn" || Tone("", 1) != "dim" { + t.Fatal(Tone("firing", 3), Tone("resolved", 5), Tone("", 5), Tone("", 4), Tone("", 1)) + } +} + +func TestBodyView(t *testing.T) { + view := BodyView(grafana, false) + if !containsAll(view, "status: firing", "alert: DiskFull", "summary: disk > 90", "alerts:") { + t.Fatalf("%s", view) + } + raw := BodyView(grafana, true) + if !containsAll(raw, "\"commonLabels\"", "DiskFull") { + t.Fatalf("%s", raw) + } + pretty := BodyView(`{"a":1}`, false) + if !strings.Contains(pretty, "\"a\": 1") { + t.Fatalf("%s", pretty) + } +} + +func containsAll(s string, parts ...string) bool { + for _, p := range parts { + if !strings.Contains(s, p) { + return false + } + } + return true +}