Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add card feature to msteamsv2_config #4243

Open
wants to merge 10 commits into
base: main
Choose a base branch
from
1 change: 1 addition & 0 deletions config/notifiers.go
Original file line number Diff line number Diff line change
Expand Up @@ -872,6 +872,7 @@ type MSTeamsV2Config struct {

Title string `yaml:"title,omitempty" json:"title,omitempty"`
Text string `yaml:"text,omitempty" json:"text,omitempty"`
Card string `yaml:"card,omitempty" json:"card,omitempty"`
}

func (c *MSTeamsV2Config) UnmarshalYAML(unmarshal func(interface{}) error) error {
Expand Down
5 changes: 5 additions & 0 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -992,6 +992,11 @@ Microsoft Teams v2 notifications using the new message format with adaptive card
# Message body template.
[ text: <tmpl_string> | default = '{{ template "msteamsv2.default.text" . }}' ]

# Message body card.
# If not null, it will override title and text values (no need to configure these values)
# You can find a complete sample file template here 'examples/msteamsv2/card.tmpl' and provide '{{ template "msteams.card" . }}' in the card value to test.
[ card: <tmpl_string> ]

# The HTTP client's configuration.
[ http_config: <http_config> | default = global.http_config ]
```
Expand Down
75 changes: 75 additions & 0 deletions examples/msteamsv2/card.tmpl
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
{{ define "msteams.card" }}
{
"Type": "message",
"Attachments": [
{
"contentType": "application/vnd.microsoft.card.adaptive",
"content": {
"$schema": "http://adaptivecards.io/schemas/adaptive-card.json",
"type": "AdaptiveCard",
"version": "1.4",
"msteams": {
"width": "Full"
},
"body": [
{
"type": "ColumnSet",
"style": "{{ if eq .Status "firing" }}attention{{ else if eq .Status "resolved" }}good{{ else }}warning{{ end }}",
"columns": [
{
"type": "Column",
"width": "stretch",
"items": [
{
"type": "TextBlock",
"weight": "Bolder",
"size": "ExtraLarge",
"color": "{{ if eq .Status "firing" }}attention{{ else if eq .Status "resolved" }}good{{ else }}warning{{ end }}",
"text": "{{ if eq .Status "firing" }}🔥{{ else if eq .Status "resolved" }}✅{{ else }}⚠️{{ end }} Prometheus alert {{ if eq .Status "resolved" }}(Resolved){{ else if eq .Status "firing" }}(Firing){{ else if eq .Status "unknown" }}(Unknown){{ else }}(Warning){{ end }}"
},
{
"type": "TextBlock",
"weight": "Bolder",
"size": "ExtraLarge",
"text": "{{ if eq .Status "resolved" }}(Resolved) {{ end }}{{ .CommonAnnotations.summary }}",
"wrap": true
}
]
}
]
},
{
"type": "FactSet",
"facts": [
{ "title": "Status", "value": "{{ .Status }} {{ if eq .Status "firing" }}🔥{{ else if eq .Status "resolved" }}✅{{ else }}⚠️{{ end }}" }
{{ if .CommonLabels.alertname }}, { "title": "Alert", "value": "{{ .CommonLabels.alertname }}" }{{ end }}
{{ if .CommonLabels.instance }}, { "title": "In host", "value": "{{ .CommonLabels.instance }}" }{{ end }}
{{ if .CommonLabels.severity }}, { "title": "Severity", "value": "{{ .CommonLabels.severity }} {{ if eq .CommonLabels.severity "critical" }}❌{{ else if eq .CommonLabels.severity "error" }}❗️{{ else if eq .CommonLabels.severity "warning" }}⚠️{{ else if eq .CommonLabels.severity "info" }}ℹ️{{ else }}❓{{ end }}" }{{ end }}
{{ if .CommonAnnotations.description }}, { "title": "Description", "value": "{{ .CommonAnnotations.description }}" }{{ end }}
{{- range $key, $value := .CommonLabels }}
{{- if and (ne $key "alertname") (ne $key "instance") (ne $key "severity") }}
, { "title": "{{ $key }}", "value": "{{ $value }}" }
{{- end }}
{{- end }}
{{- range $key, $value := .CommonAnnotations }}
{{- if and (ne $key "summary") (ne $key "description") }}
, { "title": "{{ $key }}", "value": "{{ $value }}" }
{{- end }}
{{- end }}
]
}
]
{{ if .CommonAnnotations.runbook_url }},
"actions": [
{
"type": "Action.OpenUrl",
"title": "View details",
"url": "{{ .CommonAnnotations.runbook_url }}"
}
]
{{ end }}
}
}
]
}
{{ end }}
223 changes: 184 additions & 39 deletions notify/msteamsv2/msteamsv2.go
Original file line number Diff line number Diff line change
Expand Up @@ -49,23 +49,53 @@ type Notifier struct {
postJSONFunc func(ctx context.Context, client *http.Client, url string, body io.Reader) (*http.Response, error)
}

type Action struct {
Type string `json:"type"`
Title string `json:"title"`
URL string `json:"url"`
}

// https://learn.microsoft.com/en-us/connectors/teams/?tabs=text1#adaptivecarditemschema
type Content struct {
Schema string `json:"$schema"`
Type string `json:"type"`
Version string `json:"version"`
Body []Body `json:"body"`
Msteams Msteams `json:"msteams,omitempty"`
Schema string `json:"$schema"`
Type string `json:"type"`
Version string `json:"version"`
Body []Body `json:"body"`
Msteams Msteams `json:"msteams,omitempty"`
Actions []Action `json:"actions,omitempty"`
}

type Body struct {
type Item struct {
Type string `json:"type"`
Text string `json:"text"`
Weight string `json:"weight,omitempty"`
Size string `json:"size,omitempty"`
Wrap bool `json:"wrap,omitempty"`
Style string `json:"style,omitempty"`
Color string `json:"color,omitempty"`
Text string `json:"text"`
}

type Column struct {
Type string `json:"type"`
Width string `json:"width"`
Items []Item `json:"items"`
}

type Fact struct {
Title string `json:"title"`
Value string `json:"value"`
}

type Body struct {
Type string `json:"type"`
Text string `json:"text"`
Weight string `json:"weight,omitempty"`
Size string `json:"size,omitempty"`
Wrap bool `json:"wrap,omitempty"`
Style string `json:"style,omitempty"`
Color string `json:"color,omitempty"`
Columns []Column `json:"columns,omitempty"`
Facts []Fact `json:"facts,omitempty"`
}

type Msteams struct {
Expand Down Expand Up @@ -125,14 +155,26 @@ func (n *Notifier) Notify(ctx context.Context, as ...*types.Alert) (bool, error)
if err != nil {
return false, err
}
card := tmpl(n.conf.Card)
if err != nil {
return false, err
}

alerts := types.Alerts(as...)
// summary := ""
color := colorGrey
status := "unknown"
statusIcon := "⚠"

switch alerts.Status() {
case model.AlertFiring:
color = colorRed
status = "firing"
statusIcon = "🔥"
case model.AlertResolved:
color = colorGreen
status = "resolved"
statusIcon = "✅"
}

var url string
Expand All @@ -146,44 +188,113 @@ func (n *Notifier) Notify(ctx context.Context, as ...*types.Alert) (bool, error)
url = strings.TrimSpace(string(content))
}

// A message as referenced in https://learn.microsoft.com/en-us/connectors/teams/?tabs=text1%2Cdotnet#request-body-schema
t := teamsMessage{
Type: "message",
Attachments: []Attachment{
{
ContentType: "application/vnd.microsoft.card.adaptive",
ContentURL: nil,
Content: Content{
Schema: "http://adaptivecards.io/schemas/adaptive-card.json",
Type: "AdaptiveCard",
Version: "1.2",
Body: []Body{
{
Type: "TextBlock",
Text: title,
Weight: "Bolder",
Size: "Medium",
Wrap: true,
Style: "heading",
Color: color,
// If the card is empty, use title and text otherwise use card.
var payload bytes.Buffer
if card == "" {
// A message as referenced in https://learn.microsoft.com/en-us/connectors/teams/?tabs=text1%2Cdotnet#request-body-schema
t := teamsMessage{
Type: "message",
Attachments: []Attachment{
{
ContentType: "application/vnd.microsoft.card.adaptive",
ContentURL: nil,
Content: Content{
Schema: "http://adaptivecards.io/schemas/adaptive-card.json",
Type: "AdaptiveCard",
Version: "1.4",
Msteams: Msteams{
Width: "Full"},
Body: []Body{
{
Type: "ColumnSet",
Style: color,
Columns: []Column{
{
Type: "Column",
Width: "stretch",
Items: []Item{
{
Type: "TextBlock",
Weight: "Bolder",
Size: "ExtraLarge",
Color: color,
Text: fmt.Sprintf("%s %s", statusIcon, title),
},
{
Type: "TextBlock",
Weight: "Bolder",
Size: "ExtraLarge",
Text: text,
Wrap: true,
},
},
},
},
},
{
Type: "FactSet",
Facts: []Fact{
{
Title: "Status",
Value: fmt.Sprintf("%s %s", status, statusIcon),
},
{
Title: "Alert",
Value: extractKV(data.CommonLabels, "alertname"),
},
{
Title: "Summary",
Value: extractKV(data.CommonAnnotations, "summary"),
},
{
Title: "Severity",
Value: renderSeverity(extractKV(data.CommonLabels, "severity")),
},
{
Title: "In Host",
Value: extractKV(data.CommonLabels, "instance"),
},
{
Title: "Description",
Value: extractKV(data.CommonAnnotations, "description"),
},
{
Title: "Common Labels",
Value: renderCommonLabels(data.CommonLabels),
},
{
Title: "Common Annotations",
Value: renderCommonAnnotations(data.CommonAnnotations),
},
},
},
},
{
Type: "TextBlock",
Text: text,
Wrap: true,
Actions: []Action{
{
Type: "Action.OpenUrl",
Title: "View details",
URL: extractKV(data.CommonAnnotations, "runbook_url"),
},
},
},
Msteams: Msteams{
Width: "full",
},
},
},
},
}
}

var payload bytes.Buffer
if err = json.NewEncoder(&payload).Encode(t); err != nil {
return false, err
// Check if summary exists in CommonLabels

if err = json.NewEncoder(&payload).Encode(t); err != nil {
return false, err
}
} else {
// Transform card string into object
var jsonMap map[string]interface{}
json.Unmarshal([]byte(card), &jsonMap)
n.logger.Debug("jsonMap", "jsonMap", jsonMap)

if err = json.NewEncoder(&payload).Encode(jsonMap); err != nil {
return false, err
}
}

resp, err := n.postJSONFunc(ctx, n.client, url, &payload)
Expand All @@ -199,3 +310,37 @@ func (n *Notifier) Notify(ctx context.Context, as ...*types.Alert) (bool, error)
}
return shouldRetry, err
}

func renderSeverity(severity string) string {
switch severity {
case "critical":
return fmt.Sprintf("%s %s", severity, "❌")
case "error":
return fmt.Sprintf("%s %s", severity, "❗️")
case "warning":
return fmt.Sprintf("%s %s", severity, "⚠️")
case "info":
return fmt.Sprintf("%s %s", severity, "ℹ️")
default:
return "unknown ❓"
}
}

func renderCommonLabels(commonLabels template.KV) string {
removeList := []string{"alertname", "instance", "severity"}

return commonLabels.Remove(removeList).String()
}

func renderCommonAnnotations(commonLabels template.KV) string {
removeList := []string{"summary", "description"}

return commonLabels.Remove(removeList).String()
}

func extractKV(kv template.KV, key string) string {
if v, ok := kv[key]; ok {
return v
}
return ""
}