mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-05-07 17:19:57 +00:00
Forward-port webhook JSON template escaping
This commit is contained in:
parent
4564742c18
commit
b8a551ce22
6 changed files with 254 additions and 135 deletions
|
|
@ -84,6 +84,14 @@ template registry instead of keeping a second hardcoded service list. Mention
|
|||
field visibility plus mention-placeholder/help copy for supported services
|
||||
must also come from the same backend registry so the editor does not carry a
|
||||
second service-specific presentation map.
|
||||
That same template-registry boundary owns JSON-safe string rendering for the
|
||||
built-in webhook providers. Canonical JSON templates must render runtime
|
||||
strings through the shared notification template helper that JSON-escapes
|
||||
quoted, multi-line, and path-like alert content before validation, instead of
|
||||
injecting raw alert fields directly into JSON bodies. Custom user templates
|
||||
may still choose their own formatting, but the shipped provider templates may
|
||||
not rely on callers to pre-sanitize alert text or resource names just to keep
|
||||
their JSON payloads valid.
|
||||
Email single-alert, grouped, resolved, and HTML send paths must follow that
|
||||
same ownership rule: they may expose different calling surfaces, but they must
|
||||
all route through one canonical enhanced email executor instead of rebuilding
|
||||
|
|
|
|||
|
|
@ -2478,6 +2478,16 @@ func templateFuncMap() template.FuncMap {
|
|||
}
|
||||
return strings.ToUpper(s[:1]) + strings.ToLower(s[1:])
|
||||
},
|
||||
"jsonString": func(v interface{}) string {
|
||||
encoded, err := json.Marshal(v)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
if len(encoded) >= 2 && encoded[0] == '"' && encoded[len(encoded)-1] == '"' {
|
||||
return string(encoded[1 : len(encoded)-1])
|
||||
}
|
||||
return string(encoded)
|
||||
},
|
||||
"upper": strings.ToUpper,
|
||||
"lower": strings.ToLower,
|
||||
"printf": fmt.Sprintf,
|
||||
|
|
|
|||
|
|
@ -132,6 +132,81 @@ func TestSendGroupedWebhookGeneric(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestSendGroupedWebhookDiscordEscapesSpecialCharacters(t *testing.T) {
|
||||
var gotBody []byte
|
||||
|
||||
server := newIPv4HTTPServer(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
body, _ := io.ReadAll(r.Body)
|
||||
gotBody = body
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
manager := NewNotificationManager("https://pulse.example")
|
||||
defer manager.Stop()
|
||||
manager.webhookClient = server.Client()
|
||||
if err := manager.UpdateAllowedPrivateCIDRs("127.0.0.1/32"); err != nil {
|
||||
t.Fatalf("failed to allowlist localhost: %v", err)
|
||||
}
|
||||
|
||||
alert := &alerts.Alert{
|
||||
ID: "alert-1",
|
||||
Type: "cpu",
|
||||
Level: alerts.AlertLevelWarning,
|
||||
ResourceID: "vm-100",
|
||||
ResourceName: `db "primary"`,
|
||||
Node: `node\01`,
|
||||
Message: "CPU spike on \"db\"\nPath C:\\temp",
|
||||
Value: 92.3,
|
||||
Threshold: 80,
|
||||
StartTime: time.Now().Add(-5 * time.Minute),
|
||||
}
|
||||
|
||||
webhook := WebhookConfig{
|
||||
Name: "discord-test",
|
||||
URL: server.URL,
|
||||
Enabled: true,
|
||||
Service: "discord",
|
||||
}
|
||||
|
||||
if err := manager.sendGroupedWebhook(webhook, []*alerts.Alert{alert}); err != nil {
|
||||
t.Fatalf("sendGroupedWebhook error: %v", err)
|
||||
}
|
||||
|
||||
var payload map[string]interface{}
|
||||
if err := json.Unmarshal(gotBody, &payload); err != nil {
|
||||
t.Fatalf("unmarshal payload: %v", err)
|
||||
}
|
||||
|
||||
embeds, ok := payload["embeds"].([]interface{})
|
||||
if !ok || len(embeds) == 0 {
|
||||
t.Fatalf("expected embeds in payload, got: %v", payload)
|
||||
}
|
||||
|
||||
embed, ok := embeds[0].(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("expected embed object, got %T", embeds[0])
|
||||
}
|
||||
|
||||
description, _ := embed["description"].(string)
|
||||
if description != alert.Message {
|
||||
t.Fatalf("expected description %q, got %q", alert.Message, description)
|
||||
}
|
||||
|
||||
fields, ok := embed["fields"].([]interface{})
|
||||
if !ok || len(fields) == 0 {
|
||||
t.Fatalf("expected fields in embed, got: %v", embed)
|
||||
}
|
||||
|
||||
resourceField, ok := fields[0].(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("expected first field object, got %T", fields[0])
|
||||
}
|
||||
if resourceField["value"] != alert.ResourceName {
|
||||
t.Fatalf("expected resource field %q, got %v", alert.ResourceName, resourceField["value"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestSendResolvedWebhookHTTP(t *testing.T) {
|
||||
var gotBody []byte
|
||||
server := newIPv4HTTPServer(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
|
|
|
|||
|
|
@ -2502,6 +2502,31 @@ func TestGeneratePayloadFromTemplateWithService(t *testing.T) {
|
|||
}
|
||||
})
|
||||
|
||||
t.Run("template with jsonString helper escapes special characters", func(t *testing.T) {
|
||||
nm := &NotificationManager{}
|
||||
data := WebhookPayloadData{
|
||||
ResourceName: `db "primary"`,
|
||||
Message: "Line1\nLine2 with \"quotes\" and C:\\temp",
|
||||
}
|
||||
template := `{"resource":"{{.ResourceName | jsonString}}","message":"{{.Message | jsonString}}"}`
|
||||
|
||||
result, err := nm.generatePayloadFromTemplateWithService(template, data, "generic")
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error, got %v", err)
|
||||
}
|
||||
|
||||
var parsed map[string]interface{}
|
||||
if err := json.Unmarshal(result, &parsed); err != nil {
|
||||
t.Fatalf("result is not valid JSON: %v", err)
|
||||
}
|
||||
if parsed["resource"] != data.ResourceName {
|
||||
t.Fatalf("expected resource %q, got %v", data.ResourceName, parsed["resource"])
|
||||
}
|
||||
if parsed["message"] != data.Message {
|
||||
t.Fatalf("expected message %q, got %v", data.Message, parsed["message"])
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("telegram service validates JSON", func(t *testing.T) {
|
||||
nm := &NotificationManager{}
|
||||
data := WebhookPayloadData{
|
||||
|
|
|
|||
|
|
@ -400,7 +400,8 @@ func TestGetWebhookTemplates_PagerDutySettings(t *testing.T) {
|
|||
if !strings.Contains(pagerduty.PayloadTemplate, "event_action") {
|
||||
t.Error("PagerDuty PayloadTemplate should contain 'event_action'")
|
||||
}
|
||||
if !strings.Contains(pagerduty.PayloadTemplate, `"alert_identifier": "{{.ID}}"`) {
|
||||
if !strings.Contains(pagerduty.PayloadTemplate, `"alert_identifier":`) ||
|
||||
!strings.Contains(pagerduty.PayloadTemplate, `{{.ID | jsonString}}`) {
|
||||
t.Error("PagerDuty PayloadTemplate should contain canonical 'alert_identifier'")
|
||||
}
|
||||
if strings.Contains(pagerduty.PayloadTemplate, legacyPagerDutyAlertIdentifierField) {
|
||||
|
|
|
|||
|
|
@ -31,20 +31,20 @@ func GetWebhookTemplates() []WebhookTemplate {
|
|||
Headers: map[string]string{"Content-Type": "application/json"},
|
||||
PayloadTemplate: `{
|
||||
"username": "Pulse Monitoring",
|
||||
{{if .Mention}}"content": "{{.Mention}}",{{end}}
|
||||
{{if .Mention}}"content": "{{.Mention | jsonString}}",{{end}}
|
||||
"embeds": [{
|
||||
"title": "Pulse Alert: {{.Level | title}}",
|
||||
"description": "{{.Message}}",
|
||||
"title": "Pulse Alert: {{.Level | title | jsonString}}",
|
||||
"description": "{{.Message | jsonString}}",
|
||||
"color": {{if eq .Level "critical"}}15158332{{else if eq .Level "warning"}}15105570{{else}}3447003{{end}},
|
||||
"fields": [
|
||||
{"name": "Resource", "value": "{{.ResourceName}}", "inline": true},
|
||||
{"name": "Node", "value": "{{.Node}}", "inline": true},
|
||||
{"name": "Type", "value": "{{.Type | title}}", "inline": true},
|
||||
{"name": "Resource", "value": "{{.ResourceName | jsonString}}", "inline": true},
|
||||
{"name": "Node", "value": "{{.Node | jsonString}}", "inline": true},
|
||||
{"name": "Type", "value": "{{.Type | title | jsonString}}", "inline": true},
|
||||
{"name": "Value", "value": "{{if or (eq .Type "diskRead") (eq .Type "diskWrite")}}{{printf "%.1f" .Value}} MB/s{{else}}{{printf "%.1f" .Value}}%{{end}}", "inline": true},
|
||||
{"name": "Threshold", "value": "{{if or (eq .Type "diskRead") (eq .Type "diskWrite")}}{{printf "%.0f" .Threshold}} MB/s{{else}}{{printf "%.0f" .Threshold}}%{{end}}", "inline": true},
|
||||
{"name": "Duration", "value": "{{.Duration}}", "inline": true}
|
||||
{"name": "Duration", "value": "{{.Duration | jsonString}}", "inline": true}
|
||||
],
|
||||
"timestamp": "{{.Timestamp}}",
|
||||
"timestamp": "{{.Timestamp | jsonString}}",
|
||||
"footer": {
|
||||
"text": "Pulse Monitoring"
|
||||
}
|
||||
|
|
@ -53,17 +53,17 @@ func GetWebhookTemplates() []WebhookTemplate {
|
|||
ResolvedPayloadTemplate: `{
|
||||
"username": "Pulse Monitoring",
|
||||
"embeds": [{
|
||||
"title": "Resolved: {{.ResourceName}}",
|
||||
"description": "{{.Message}}",
|
||||
"title": "Resolved: {{.ResourceName | jsonString}}",
|
||||
"description": "{{.Message | jsonString}}",
|
||||
"color": 3066993,
|
||||
"fields": [
|
||||
{"name": "Resource", "value": "{{.ResourceName}}", "inline": true},
|
||||
{"name": "Node", "value": "{{.Node}}", "inline": true},
|
||||
{"name": "Type", "value": "{{.Type | title}}", "inline": true},
|
||||
{"name": "Duration", "value": "{{.Duration}}", "inline": true},
|
||||
{"name": "Resolved At", "value": "{{.ResolvedAt}}", "inline": true}
|
||||
{"name": "Resource", "value": "{{.ResourceName | jsonString}}", "inline": true},
|
||||
{"name": "Node", "value": "{{.Node | jsonString}}", "inline": true},
|
||||
{"name": "Type", "value": "{{.Type | title | jsonString}}", "inline": true},
|
||||
{"name": "Duration", "value": "{{.Duration | jsonString}}", "inline": true},
|
||||
{"name": "Resolved At", "value": "{{.ResolvedAt | jsonString}}", "inline": true}
|
||||
],
|
||||
"timestamp": "{{.Timestamp}}",
|
||||
"timestamp": "{{.Timestamp | jsonString}}",
|
||||
"footer": {
|
||||
"text": "Pulse Monitoring"
|
||||
}
|
||||
|
|
@ -80,14 +80,14 @@ func GetWebhookTemplates() []WebhookTemplate {
|
|||
Method: "POST",
|
||||
Headers: map[string]string{"Content-Type": "application/json"},
|
||||
PayloadTemplate: `{
|
||||
"chat_id": "{{.ChatID}}",
|
||||
"text": "*Pulse Alert: {{.Level | title}}*\n\n{{.Message}}\n\n*Details:*\n• Resource: {{.ResourceName}}\n• Node: {{.Node}}\n• Type: {{.Type | title}}\n• Value: {{if or (eq .Type "diskRead") (eq .Type "diskWrite")}}{{printf "%.1f" .Value}} MB/s{{else}}{{printf "%.1f" .Value}}%{{end}}\n• Threshold: {{if or (eq .Type "diskRead") (eq .Type "diskWrite")}}{{printf "%.0f" .Threshold}} MB/s{{else}}{{printf "%.0f" .Threshold}}%{{end}}\n• Duration: {{.Duration}}\n\n[View in Pulse]({{.Instance}})",
|
||||
"chat_id": "{{.ChatID | jsonString}}",
|
||||
"text": "*Pulse Alert: {{.Level | title | jsonString}}*\n\n{{.Message | jsonString}}\n\n*Details:*\n• Resource: {{.ResourceName | jsonString}}\n• Node: {{.Node | jsonString}}\n• Type: {{.Type | title | jsonString}}\n• Value: {{if or (eq .Type "diskRead") (eq .Type "diskWrite")}}{{printf "%.1f" .Value}} MB/s{{else}}{{printf "%.1f" .Value}}%{{end}}\n• Threshold: {{if or (eq .Type "diskRead") (eq .Type "diskWrite")}}{{printf "%.0f" .Threshold}} MB/s{{else}}{{printf "%.0f" .Threshold}}%{{end}}\n• Duration: {{.Duration | jsonString}}\n\n[View in Pulse]({{.Instance | jsonString}})",
|
||||
"parse_mode": "Markdown",
|
||||
"disable_web_page_preview": true
|
||||
}`,
|
||||
ResolvedPayloadTemplate: `{
|
||||
"chat_id": "{{.ChatID}}",
|
||||
"text": "*Resolved: {{.ResourceName}}*\n\n{{.Message}}\n\n*Details:*\n• Resource: {{.ResourceName}}\n• Node: {{.Node}}\n• Type: {{.Type | title}}\n• Duration: {{.Duration}}\n• Resolved At: {{.ResolvedAt}}",
|
||||
"chat_id": "{{.ChatID | jsonString}}",
|
||||
"text": "*Resolved: {{.ResourceName | jsonString}}*\n\n{{.Message | jsonString}}\n\n*Details:*\n• Resource: {{.ResourceName | jsonString}}\n• Node: {{.Node | jsonString}}\n• Type: {{.Type | title | jsonString}}\n• Duration: {{.Duration | jsonString}}\n• Resolved At: {{.ResolvedAt | jsonString}}",
|
||||
"parse_mode": "Markdown",
|
||||
"disable_web_page_preview": true
|
||||
}`,
|
||||
|
|
@ -104,20 +104,20 @@ func GetWebhookTemplates() []WebhookTemplate {
|
|||
Method: "POST",
|
||||
Headers: map[string]string{"Content-Type": "application/json"},
|
||||
PayloadTemplate: `{
|
||||
"text": "{{if .Mention}}{{.Mention}} {{end}}Pulse Alert: {{.Level | title}} - {{.ResourceName}}",
|
||||
"text": "{{if .Mention}}{{.Mention | jsonString}} {{end}}Pulse Alert: {{.Level | title | jsonString}} - {{.ResourceName | jsonString}}",
|
||||
"blocks": [
|
||||
{{if .Mention}}{
|
||||
"type": "section",
|
||||
"text": {
|
||||
"type": "mrkdwn",
|
||||
"text": "{{.Mention}}"
|
||||
"text": "{{.Mention | jsonString}}"
|
||||
}
|
||||
},{{end}}
|
||||
{
|
||||
"type": "header",
|
||||
"text": {
|
||||
"type": "plain_text",
|
||||
"text": "Pulse Alert: {{.Level | title}}",
|
||||
"text": "Pulse Alert: {{.Level | title | jsonString}}",
|
||||
"emoji": true
|
||||
}
|
||||
},
|
||||
|
|
@ -125,18 +125,18 @@ func GetWebhookTemplates() []WebhookTemplate {
|
|||
"type": "section",
|
||||
"text": {
|
||||
"type": "mrkdwn",
|
||||
"text": "{{.Message}}"
|
||||
"text": "{{.Message | jsonString}}"
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "section",
|
||||
"fields": [
|
||||
{"type": "mrkdwn", "text": "*Resource:*\n{{.ResourceName}}"},
|
||||
{"type": "mrkdwn", "text": "*Node:*\n{{.Node}}"},
|
||||
{"type": "mrkdwn", "text": "*Type:*\n{{.Type | title}}"},
|
||||
{"type": "mrkdwn", "text": "*Resource:*\n{{.ResourceName | jsonString}}"},
|
||||
{"type": "mrkdwn", "text": "*Node:*\n{{.Node | jsonString}}"},
|
||||
{"type": "mrkdwn", "text": "*Type:*\n{{.Type | title | jsonString}}"},
|
||||
{"type": "mrkdwn", "text": "*Value:*\n{{if or (eq .Type "diskRead") (eq .Type "diskWrite")}}{{printf "%.1f" .Value}} MB/s{{else}}{{printf "%.1f" .Value}}%{{end}}"},
|
||||
{"type": "mrkdwn", "text": "*Threshold:*\n{{if or (eq .Type "diskRead") (eq .Type "diskWrite")}}{{printf "%.0f" .Threshold}} MB/s{{else}}{{printf "%.0f" .Threshold}}%{{end}}"},
|
||||
{"type": "mrkdwn", "text": "*Duration:*\n{{.Duration}}"}
|
||||
{"type": "mrkdwn", "text": "*Duration:*\n{{.Duration | jsonString}}"}
|
||||
]
|
||||
},
|
||||
{
|
||||
|
|
@ -144,20 +144,20 @@ func GetWebhookTemplates() []WebhookTemplate {
|
|||
"elements": [
|
||||
{
|
||||
"type": "mrkdwn",
|
||||
"text": "View in <{{.Instance}}|Proxmox> | Alert Identifier: {{.ID}}"
|
||||
"text": "View in <{{.Instance | jsonString}}|Proxmox> | Alert Identifier: {{.ID | jsonString}}"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}`,
|
||||
ResolvedPayloadTemplate: `{
|
||||
"text": "Resolved: {{.ResourceName}}",
|
||||
"text": "Resolved: {{.ResourceName | jsonString}}",
|
||||
"blocks": [
|
||||
{
|
||||
"type": "header",
|
||||
"text": {
|
||||
"type": "plain_text",
|
||||
"text": "Resolved: {{.ResourceName}}",
|
||||
"text": "Resolved: {{.ResourceName | jsonString}}",
|
||||
"emoji": true
|
||||
}
|
||||
},
|
||||
|
|
@ -165,17 +165,17 @@ func GetWebhookTemplates() []WebhookTemplate {
|
|||
"type": "section",
|
||||
"text": {
|
||||
"type": "mrkdwn",
|
||||
"text": "{{.Message}}"
|
||||
"text": "{{.Message | jsonString}}"
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "section",
|
||||
"fields": [
|
||||
{"type": "mrkdwn", "text": "*Resource:*\n{{.ResourceName}}"},
|
||||
{"type": "mrkdwn", "text": "*Node:*\n{{.Node}}"},
|
||||
{"type": "mrkdwn", "text": "*Type:*\n{{.Type | title}}"},
|
||||
{"type": "mrkdwn", "text": "*Duration:*\n{{.Duration}}"},
|
||||
{"type": "mrkdwn", "text": "*Resolved At:*\n{{.ResolvedAt}}"}
|
||||
{"type": "mrkdwn", "text": "*Resource:*\n{{.ResourceName | jsonString}}"},
|
||||
{"type": "mrkdwn", "text": "*Node:*\n{{.Node | jsonString}}"},
|
||||
{"type": "mrkdwn", "text": "*Type:*\n{{.Type | title | jsonString}}"},
|
||||
{"type": "mrkdwn", "text": "*Duration:*\n{{.Duration | jsonString}}"},
|
||||
{"type": "mrkdwn", "text": "*Resolved At:*\n{{.ResolvedAt | jsonString}}"}
|
||||
]
|
||||
},
|
||||
{
|
||||
|
|
@ -183,7 +183,7 @@ func GetWebhookTemplates() []WebhookTemplate {
|
|||
"elements": [
|
||||
{
|
||||
"type": "mrkdwn",
|
||||
"text": "Alert Identifier: {{.ID}}"
|
||||
"text": "Alert Identifier: {{.ID | jsonString}}"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -205,19 +205,19 @@ func GetWebhookTemplates() []WebhookTemplate {
|
|||
"@type": "MessageCard",
|
||||
"@context": "http://schema.org/extensions",
|
||||
"themeColor": "{{if eq .Level "critical"}}FF0000{{else if eq .Level "warning"}}FFA500{{else}}00FF00{{end}}",
|
||||
"summary": "Pulse Alert: {{.Level | title}} - {{.ResourceName}}",
|
||||
{{if .Mention}}"text": "{{.Mention}}",{{end}}
|
||||
"summary": "Pulse Alert: {{.Level | title | jsonString}} - {{.ResourceName | jsonString}}",
|
||||
{{if .Mention}}"text": "{{.Mention | jsonString}}",{{end}}
|
||||
"sections": [{
|
||||
"activityTitle": "Pulse Alert: {{.Level | title}}",
|
||||
"activitySubtitle": "{{.Message}}",
|
||||
"activityTitle": "Pulse Alert: {{.Level | title | jsonString}}",
|
||||
"activitySubtitle": "{{.Message | jsonString}}",
|
||||
"facts": [
|
||||
{"name": "Resource", "value": "{{.ResourceName}}"},
|
||||
{"name": "Node", "value": "{{.Node}}"},
|
||||
{"name": "Type", "value": "{{.Type | title}}"},
|
||||
{"name": "Resource", "value": "{{.ResourceName | jsonString}}"},
|
||||
{"name": "Node", "value": "{{.Node | jsonString}}"},
|
||||
{"name": "Type", "value": "{{.Type | title | jsonString}}"},
|
||||
{"name": "Value", "value": "{{if or (eq .Type "diskRead") (eq .Type "diskWrite")}}{{printf "%.1f" .Value}} MB/s{{else}}{{printf "%.1f" .Value}}%{{end}}"},
|
||||
{"name": "Threshold", "value": "{{if or (eq .Type "diskRead") (eq .Type "diskWrite")}}{{printf "%.0f" .Threshold}} MB/s{{else}}{{printf "%.0f" .Threshold}}%{{end}}"},
|
||||
{"name": "Duration", "value": "{{.Duration}}"},
|
||||
{"name": "Instance", "value": "{{.Instance}}"}
|
||||
{"name": "Duration", "value": "{{.Duration | jsonString}}"},
|
||||
{"name": "Instance", "value": "{{.Instance | jsonString}}"}
|
||||
],
|
||||
"markdown": true
|
||||
}],
|
||||
|
|
@ -226,7 +226,7 @@ func GetWebhookTemplates() []WebhookTemplate {
|
|||
"name": "View in Proxmox",
|
||||
"targets": [{
|
||||
"os": "default",
|
||||
"uri": "{{.Instance}}"
|
||||
"uri": "{{.Instance | jsonString}}"
|
||||
}]
|
||||
}]
|
||||
}`,
|
||||
|
|
@ -234,16 +234,16 @@ func GetWebhookTemplates() []WebhookTemplate {
|
|||
"@type": "MessageCard",
|
||||
"@context": "http://schema.org/extensions",
|
||||
"themeColor": "2DC72D",
|
||||
"summary": "Resolved: {{.ResourceName}}",
|
||||
"summary": "Resolved: {{.ResourceName | jsonString}}",
|
||||
"sections": [{
|
||||
"activityTitle": "Resolved: {{.ResourceName}}",
|
||||
"activitySubtitle": "{{.Message}}",
|
||||
"activityTitle": "Resolved: {{.ResourceName | jsonString}}",
|
||||
"activitySubtitle": "{{.Message | jsonString}}",
|
||||
"facts": [
|
||||
{"name": "Resource", "value": "{{.ResourceName}}"},
|
||||
{"name": "Node", "value": "{{.Node}}"},
|
||||
{"name": "Type", "value": "{{.Type | title}}"},
|
||||
{"name": "Duration", "value": "{{.Duration}}"},
|
||||
{"name": "Resolved At", "value": "{{.ResolvedAt}}"}
|
||||
{"name": "Resource", "value": "{{.ResourceName | jsonString}}"},
|
||||
{"name": "Node", "value": "{{.Node | jsonString}}"},
|
||||
{"name": "Type", "value": "{{.Type | title | jsonString}}"},
|
||||
{"name": "Duration", "value": "{{.Duration | jsonString}}"},
|
||||
{"name": "Resolved At", "value": "{{.ResolvedAt | jsonString}}"}
|
||||
],
|
||||
"markdown": true
|
||||
}]
|
||||
|
|
@ -262,37 +262,37 @@ func GetWebhookTemplates() []WebhookTemplate {
|
|||
"Accept": "application/vnd.pagerduty+json;version=2",
|
||||
},
|
||||
PayloadTemplate: `{
|
||||
"routing_key": "{{.CustomFields.routing_key}}",
|
||||
"routing_key": "{{.CustomFields.routing_key | jsonString}}",
|
||||
"event_action": "trigger",
|
||||
"dedup_key": "{{.ID}}",
|
||||
"dedup_key": "{{.ID | jsonString}}",
|
||||
"payload": {
|
||||
"summary": "{{.Message}}",
|
||||
"timestamp": "{{.Timestamp}}",
|
||||
"summary": "{{.Message | jsonString}}",
|
||||
"timestamp": "{{.Timestamp | jsonString}}",
|
||||
"severity": "{{if eq .Level "critical"}}critical{{else if eq .Level "warning"}}warning{{else}}info{{end}}",
|
||||
"source": "{{.Node}}",
|
||||
"component": "{{.ResourceName}}",
|
||||
"group": "{{.Type}}",
|
||||
"class": "{{.Type}}",
|
||||
"source": "{{.Node | jsonString}}",
|
||||
"component": "{{.ResourceName | jsonString}}",
|
||||
"group": "{{.Type | jsonString}}",
|
||||
"class": "{{.Type | jsonString}}",
|
||||
"custom_details": {
|
||||
"alert_identifier": "{{.ID}}",
|
||||
"resource_type": "{{.Type}}",
|
||||
"alert_identifier": "{{.ID | jsonString}}",
|
||||
"resource_type": "{{.Type | jsonString}}",
|
||||
"current_value": "{{if or (eq .Type "diskRead") (eq .Type "diskWrite")}}{{printf "%.1f" .Value}} MB/s{{else}}{{printf "%.1f" .Value}}%{{end}}",
|
||||
"threshold": "{{if or (eq .Type "diskRead") (eq .Type "diskWrite")}}{{printf "%.0f" .Threshold}} MB/s{{else}}{{printf "%.0f" .Threshold}}%{{end}}",
|
||||
"duration": "{{.Duration}}",
|
||||
"instance": "{{.Instance}}"
|
||||
"duration": "{{.Duration | jsonString}}",
|
||||
"instance": "{{.Instance | jsonString}}"
|
||||
}
|
||||
},
|
||||
"client": "Pulse Monitoring",
|
||||
"client_url": "{{.Instance}}",
|
||||
"client_url": "{{.Instance | jsonString}}",
|
||||
"links": [{
|
||||
"href": "{{.Instance}}",
|
||||
"href": "{{.Instance | jsonString}}",
|
||||
"text": "View in Proxmox"
|
||||
}]
|
||||
}`,
|
||||
ResolvedPayloadTemplate: `{
|
||||
"routing_key": "{{.CustomFields.routing_key}}",
|
||||
"routing_key": "{{.CustomFields.routing_key | jsonString}}",
|
||||
"event_action": "resolve",
|
||||
"dedup_key": "{{.ID}}"
|
||||
"dedup_key": "{{.ID | jsonString}}"
|
||||
}`,
|
||||
Instructions: "1. In PagerDuty, go to Configuration > Services\n2. Add an integration > Events API V2\n3. Copy the Integration Key\n4. Add the key as a custom field named 'routing_key'\n\nNote: PagerDuty recommends using Events API v2 for new integrations.",
|
||||
},
|
||||
|
|
@ -324,27 +324,27 @@ func GetWebhookTemplates() []WebhookTemplate {
|
|||
},
|
||||
{
|
||||
"type": "TextBlock",
|
||||
"text": "{{.Message}}",
|
||||
"text": "{{.Message | jsonString}}",
|
||||
"wrap": true,
|
||||
"spacing": "Small"
|
||||
},
|
||||
{
|
||||
"type": "FactSet",
|
||||
"facts": [
|
||||
{"title": "Resource", "value": "{{.ResourceName}}"},
|
||||
{"title": "Node", "value": "{{.Node}}"},
|
||||
{"title": "Type", "value": "{{.Type | title}}"},
|
||||
{"title": "Resource", "value": "{{.ResourceName | jsonString}}"},
|
||||
{"title": "Node", "value": "{{.Node | jsonString}}"},
|
||||
{"title": "Type", "value": "{{.Type | title | jsonString}}"},
|
||||
{"title": "Current Value", "value": "{{printf "%.1f" .Value}}%"},
|
||||
{"title": "Threshold", "value": "{{printf "%.0f" .Threshold}}%"},
|
||||
{"title": "Duration", "value": "{{.Duration}}"},
|
||||
{"title": "Alert Identifier", "value": "{{.ID}}"}
|
||||
{"title": "Duration", "value": "{{.Duration | jsonString}}"},
|
||||
{"title": "Alert Identifier", "value": "{{.ID | jsonString}}"}
|
||||
]
|
||||
}
|
||||
],
|
||||
"actions": [{
|
||||
"type": "Action.OpenUrl",
|
||||
"title": "View in Proxmox",
|
||||
"url": "{{.Instance}}"
|
||||
"url": "{{.Instance | jsonString}}"
|
||||
}]
|
||||
}
|
||||
}]
|
||||
|
|
@ -367,19 +367,19 @@ func GetWebhookTemplates() []WebhookTemplate {
|
|||
},
|
||||
{
|
||||
"type": "TextBlock",
|
||||
"text": "{{.Message}}",
|
||||
"text": "{{.Message | jsonString}}",
|
||||
"wrap": true,
|
||||
"spacing": "Small"
|
||||
},
|
||||
{
|
||||
"type": "FactSet",
|
||||
"facts": [
|
||||
{"title": "Resource", "value": "{{.ResourceName}}"},
|
||||
{"title": "Node", "value": "{{.Node}}"},
|
||||
{"title": "Type", "value": "{{.Type | title}}"},
|
||||
{"title": "Duration", "value": "{{.Duration}}"},
|
||||
{"title": "Resolved At", "value": "{{.ResolvedAt}}"},
|
||||
{"title": "Alert Identifier", "value": "{{.ID}}"}
|
||||
{"title": "Resource", "value": "{{.ResourceName | jsonString}}"},
|
||||
{"title": "Node", "value": "{{.Node | jsonString}}"},
|
||||
{"title": "Type", "value": "{{.Type | title | jsonString}}"},
|
||||
{"title": "Duration", "value": "{{.Duration | jsonString}}"},
|
||||
{"title": "Resolved At", "value": "{{.ResolvedAt | jsonString}}"},
|
||||
{"title": "Alert Identifier", "value": "{{.ID | jsonString}}"}
|
||||
]
|
||||
}
|
||||
]
|
||||
|
|
@ -397,23 +397,23 @@ func GetWebhookTemplates() []WebhookTemplate {
|
|||
Method: "POST",
|
||||
Headers: map[string]string{"Content-Type": "application/json"},
|
||||
PayloadTemplate: `{
|
||||
"token": "{{.CustomFields.token}}",
|
||||
"user": "{{.CustomFields.user}}",
|
||||
"title": "Pulse Alert: {{.Level | title}} - {{.ResourceName}}",
|
||||
"message": "{{.Message}}\n\n• Resource: {{.ResourceName}}\n• Node: {{.Node}}\n• Type: {{.Type | title}}\n• Value: {{if or (eq .Type "diskRead") (eq .Type "diskWrite")}}{{printf "%.1f" .Value}} MB/s{{else}}{{printf "%.1f" .Value}}%{{end}}\n• Threshold: {{if or (eq .Type "diskRead") (eq .Type "diskWrite")}}{{printf "%.0f" .Threshold}} MB/s{{else}}{{printf "%.0f" .Threshold}}%{{end}}\n• Duration: {{.Duration}}",
|
||||
"token": "{{.CustomFields.token | jsonString}}",
|
||||
"user": "{{.CustomFields.user | jsonString}}",
|
||||
"title": "Pulse Alert: {{.Level | title | jsonString}} - {{.ResourceName | jsonString}}",
|
||||
"message": "{{.Message | jsonString}}\n\n• Resource: {{.ResourceName | jsonString}}\n• Node: {{.Node | jsonString}}\n• Type: {{.Type | title | jsonString}}\n• Value: {{if or (eq .Type "diskRead") (eq .Type "diskWrite")}}{{printf "%.1f" .Value}} MB/s{{else}}{{printf "%.1f" .Value}}%{{end}}\n• Threshold: {{if or (eq .Type "diskRead") (eq .Type "diskWrite")}}{{printf "%.0f" .Threshold}} MB/s{{else}}{{printf "%.0f" .Threshold}}%{{end}}\n• Duration: {{.Duration | jsonString}}",
|
||||
"priority": {{if .CustomFields.priority}}{{.CustomFields.priority}}{{else}}{{if eq .Level "critical"}}1{{else if eq .Level "warning"}}0{{else}}-1{{end}}{{end}},
|
||||
"sound": "{{if .CustomFields.sound}}{{.CustomFields.sound}}{{else}}{{if eq .Level "critical"}}siren{{else if eq .Level "warning"}}tugboat{{else}}pushover{{end}}{{end}}",
|
||||
"device": "{{if .CustomFields.device}}{{.CustomFields.device}}{{else}}{{.ResourceName}}{{end}}",
|
||||
"timestamp": "{{.Timestamp}}"
|
||||
"sound": "{{if .CustomFields.sound}}{{.CustomFields.sound | jsonString}}{{else}}{{if eq .Level "critical"}}siren{{else if eq .Level "warning"}}tugboat{{else}}pushover{{end}}{{end}}",
|
||||
"device": "{{if .CustomFields.device}}{{.CustomFields.device | jsonString}}{{else}}{{.ResourceName | jsonString}}{{end}}",
|
||||
"timestamp": "{{.Timestamp | jsonString}}"
|
||||
}`,
|
||||
ResolvedPayloadTemplate: `{
|
||||
"token": "{{.CustomFields.token}}",
|
||||
"user": "{{.CustomFields.user}}",
|
||||
"title": "Resolved: {{.ResourceName}}",
|
||||
"message": "{{.Message}}\n\n• Resource: {{.ResourceName}}\n• Node: {{.Node}}\n• Type: {{.Type | title}}\n• Duration: {{.Duration}}\n• Resolved At: {{.ResolvedAt}}",
|
||||
"token": "{{.CustomFields.token | jsonString}}",
|
||||
"user": "{{.CustomFields.user | jsonString}}",
|
||||
"title": "Resolved: {{.ResourceName | jsonString}}",
|
||||
"message": "{{.Message | jsonString}}\n\n• Resource: {{.ResourceName | jsonString}}\n• Node: {{.Node | jsonString}}\n• Type: {{.Type | title | jsonString}}\n• Duration: {{.Duration | jsonString}}\n• Resolved At: {{.ResolvedAt | jsonString}}",
|
||||
"priority": -1,
|
||||
"sound": "pushover",
|
||||
"timestamp": "{{.Timestamp}}"
|
||||
"timestamp": "{{.Timestamp | jsonString}}"
|
||||
}`,
|
||||
Instructions: "1. Create an application at https://pushover.net/apps\n2. Copy your Application Token\n3. Get your User Key from your Pushover dashboard\n4. URL: https://api.pushover.net/1/messages.json\n5. Add custom fields:\n • token: YOUR_APP_TOKEN (required)\n • user: YOUR_USER_KEY (required)\n • sound: notification sound (optional, e.g., spacealarm, siren, tugboat)\n • priority: -2 to 2 (optional, overrides level-based default)\n • device: specific device name (optional, overrides ResourceName)",
|
||||
},
|
||||
|
|
@ -426,41 +426,41 @@ func GetWebhookTemplates() []WebhookTemplate {
|
|||
Method: "POST",
|
||||
Headers: map[string]string{"Content-Type": "application/json"},
|
||||
PayloadTemplate: `{
|
||||
"message": "**{{if eq .Level "critical"}}CRITICAL{{else if eq .Level "warning"}}WARNING{{else}}INFO{{end}}**: **{{.ResourceName}}** on **{{.Node}}**\n\n{{.Message}}\n\n**Alert Details:**\n- **Resource:** {{.ResourceName}}\n- **Node:** {{.Node}}\n- **Type:** {{.Type | title}}\n- **Current:** {{if or (eq .Type "diskRead") (eq .Type "diskWrite")}}{{printf "%.1f" .Value}} MB/s{{else}}{{printf "%.1f" .Value}}%{{end}}\n- **Threshold:** {{if or (eq .Type "diskRead") (eq .Type "diskWrite")}}{{printf "%.0f" .Threshold}} MB/s{{else}}{{printf "%.0f" .Threshold}}%{{end}}\n- **Duration:** {{.Duration}}\n- **Alert Identifier:** {{.ID}}\n\n[View in Pulse]({{.Instance}})",
|
||||
"title": "{{.ResourceName}} - {{.Type | title}} {{.Level | upper}} Alert",
|
||||
"message": "**{{if eq .Level "critical"}}CRITICAL{{else if eq .Level "warning"}}WARNING{{else}}INFO{{end}}**: **{{.ResourceName | jsonString}}** on **{{.Node | jsonString}}**\n\n{{.Message | jsonString}}\n\n**Alert Details:**\n- **Resource:** {{.ResourceName | jsonString}}\n- **Node:** {{.Node | jsonString}}\n- **Type:** {{.Type | title | jsonString}}\n- **Current:** {{if or (eq .Type "diskRead") (eq .Type "diskWrite")}}{{printf "%.1f" .Value}} MB/s{{else}}{{printf "%.1f" .Value}}%{{end}}\n- **Threshold:** {{if or (eq .Type "diskRead") (eq .Type "diskWrite")}}{{printf "%.0f" .Threshold}} MB/s{{else}}{{printf "%.0f" .Threshold}}%{{end}}\n- **Duration:** {{.Duration | jsonString}}\n- **Alert Identifier:** {{.ID | jsonString}}\n\n[View in Pulse]({{.Instance | jsonString}})",
|
||||
"title": "{{.ResourceName | jsonString}} - {{.Type | title | jsonString}} {{.Level | upper | jsonString}} Alert",
|
||||
"priority": {{if eq .Level "critical"}}10{{else if eq .Level "warning"}}5{{else}}2{{end}},
|
||||
"extras": {
|
||||
"client::display": {
|
||||
"contentType": "text/markdown"
|
||||
},
|
||||
"pulse::alert": {
|
||||
"id": "{{.ID}}",
|
||||
"level": "{{.Level}}",
|
||||
"type": "{{.Type}}",
|
||||
"resource_name": "{{.ResourceName}}",
|
||||
"node": "{{.Node}}",
|
||||
"id": "{{.ID | jsonString}}",
|
||||
"level": "{{.Level | jsonString}}",
|
||||
"type": "{{.Type | jsonString}}",
|
||||
"resource_name": "{{.ResourceName | jsonString}}",
|
||||
"node": "{{.Node | jsonString}}",
|
||||
"value": {{.Value}},
|
||||
"threshold": {{.Threshold}},
|
||||
"duration": "{{.Duration}}",
|
||||
"instance": "{{.Instance}}"
|
||||
"duration": "{{.Duration | jsonString}}",
|
||||
"instance": "{{.Instance | jsonString}}"
|
||||
}
|
||||
}
|
||||
}`,
|
||||
ResolvedPayloadTemplate: `{
|
||||
"message": "**RESOLVED**: **{{.ResourceName}}** on **{{.Node}}**\n\n{{.Message}}\n\n**Details:**\n- **Resource:** {{.ResourceName}}\n- **Node:** {{.Node}}\n- **Type:** {{.Type | title}}\n- **Duration:** {{.Duration}}\n- **Resolved At:** {{.ResolvedAt}}\n- **Alert Identifier:** {{.ID}}",
|
||||
"title": "Resolved: {{.ResourceName}} - {{.Type | title}}",
|
||||
"message": "**RESOLVED**: **{{.ResourceName | jsonString}}** on **{{.Node | jsonString}}**\n\n{{.Message | jsonString}}\n\n**Details:**\n- **Resource:** {{.ResourceName | jsonString}}\n- **Node:** {{.Node | jsonString}}\n- **Type:** {{.Type | title | jsonString}}\n- **Duration:** {{.Duration | jsonString}}\n- **Resolved At:** {{.ResolvedAt | jsonString}}\n- **Alert Identifier:** {{.ID | jsonString}}",
|
||||
"title": "Resolved: {{.ResourceName | jsonString}} - {{.Type | title | jsonString}}",
|
||||
"priority": 2,
|
||||
"extras": {
|
||||
"client::display": {
|
||||
"contentType": "text/markdown"
|
||||
},
|
||||
"pulse::alert": {
|
||||
"id": "{{.ID}}",
|
||||
"id": "{{.ID | jsonString}}",
|
||||
"event": "resolved",
|
||||
"resource_name": "{{.ResourceName}}",
|
||||
"node": "{{.Node}}",
|
||||
"duration": "{{.Duration}}",
|
||||
"resolved_at": "{{.ResolvedAt}}"
|
||||
"resource_name": "{{.ResourceName | jsonString}}",
|
||||
"node": "{{.Node | jsonString}}",
|
||||
"duration": "{{.Duration | jsonString}}",
|
||||
"resolved_at": "{{.ResolvedAt | jsonString}}"
|
||||
}
|
||||
}
|
||||
}`,
|
||||
|
|
@ -507,12 +507,12 @@ View in Pulse: {{.Instance}}`,
|
|||
PayloadTemplate: `{
|
||||
"username": "Pulse Monitoring",
|
||||
"icon_url": "https://raw.githubusercontent.com/rcourtman/Pulse/main/frontend-modern/public/android-chrome-192x192.png",
|
||||
"text": "{{if eq .Level "critical"}}:rotating_light: **CRITICAL ALERT**{{else if eq .Level "warning"}}:warning: **WARNING ALERT**{{else}}:information_source: **INFO**{{end}}\n\n**{{.ResourceName}}** on **{{.Node}}**\n\n{{.Message}}\n\n| Detail | Value |\n|:-------|:------|\n| Resource | {{.ResourceName}} |\n| Node | {{.Node}} |\n| Type | {{.Type | title}} |\n| Current | {{if or (eq .Type "diskRead") (eq .Type "diskWrite")}}{{printf "%.1f" .Value}} MB/s{{else}}{{printf "%.1f" .Value}}%{{end}} |\n| Threshold | {{if or (eq .Type "diskRead") (eq .Type "diskWrite")}}{{printf "%.0f" .Threshold}} MB/s{{else}}{{printf "%.0f" .Threshold}}%{{end}} |\n| Duration | {{.Duration}} |\n| Alert Identifier | {{.ID}} |\n\n[View in Pulse]({{.Instance}})"
|
||||
"text": "{{if eq .Level "critical"}}:rotating_light: **CRITICAL ALERT**{{else if eq .Level "warning"}}:warning: **WARNING ALERT**{{else}}:information_source: **INFO**{{end}}\n\n**{{.ResourceName | jsonString}}** on **{{.Node | jsonString}}**\n\n{{.Message | jsonString}}\n\n| Detail | Value |\n|:-------|:------|\n| Resource | {{.ResourceName | jsonString}} |\n| Node | {{.Node | jsonString}} |\n| Type | {{.Type | title | jsonString}} |\n| Current | {{if or (eq .Type "diskRead") (eq .Type "diskWrite")}}{{printf "%.1f" .Value}} MB/s{{else}}{{printf "%.1f" .Value}}%{{end}} |\n| Threshold | {{if or (eq .Type "diskRead") (eq .Type "diskWrite")}}{{printf "%.0f" .Threshold}} MB/s{{else}}{{printf "%.0f" .Threshold}}%{{end}} |\n| Duration | {{.Duration | jsonString}} |\n| Alert Identifier | {{.ID | jsonString}} |\n\n[View in Pulse]({{.Instance | jsonString}})"
|
||||
}`,
|
||||
ResolvedPayloadTemplate: `{
|
||||
"username": "Pulse Monitoring",
|
||||
"icon_url": "https://raw.githubusercontent.com/rcourtman/Pulse/main/frontend-modern/public/android-chrome-192x192.png",
|
||||
"text": ":white_check_mark: **RESOLVED**\n\n**{{.ResourceName}}** on **{{.Node}}**\n\n{{.Message}}\n\n| Detail | Value |\n|:-------|:------|\n| Resource | {{.ResourceName}} |\n| Node | {{.Node}} |\n| Type | {{.Type | title}} |\n| Duration | {{.Duration}} |\n| Resolved At | {{.ResolvedAt}} |\n| Alert Identifier | {{.ID}} |"
|
||||
"text": ":white_check_mark: **RESOLVED**\n\n**{{.ResourceName | jsonString}}** on **{{.Node | jsonString}}**\n\n{{.Message | jsonString}}\n\n| Detail | Value |\n|:-------|:------|\n| Resource | {{.ResourceName | jsonString}} |\n| Node | {{.Node | jsonString}} |\n| Type | {{.Type | title | jsonString}} |\n| Duration | {{.Duration | jsonString}} |\n| Resolved At | {{.ResolvedAt | jsonString}} |\n| Alert Identifier | {{.ID | jsonString}} |"
|
||||
}`,
|
||||
Instructions: "1. In Mattermost, go to Integrations > Incoming Webhooks\n2. Create a new webhook and select the channel\n3. Copy the webhook URL and paste it here\n\nNote: This template uses Markdown formatting which is fully supported by Mattermost.",
|
||||
},
|
||||
|
|
@ -526,33 +526,33 @@ View in Pulse: {{.Instance}}`,
|
|||
Headers: map[string]string{"Content-Type": "application/json"},
|
||||
PayloadTemplate: `{
|
||||
"alert": {
|
||||
"id": "{{.ID}}",
|
||||
"level": "{{.Level}}",
|
||||
"type": "{{.Type}}",
|
||||
"resource_name": "{{.ResourceName}}",
|
||||
"node": "{{.Node}}",
|
||||
"message": "{{.Message}}",
|
||||
"id": "{{.ID | jsonString}}",
|
||||
"level": "{{.Level | jsonString}}",
|
||||
"type": "{{.Type | jsonString}}",
|
||||
"resource_name": "{{.ResourceName | jsonString}}",
|
||||
"node": "{{.Node | jsonString}}",
|
||||
"message": "{{.Message | jsonString}}",
|
||||
"value": {{.Value}},
|
||||
"threshold": {{.Threshold}},
|
||||
"start_time": "{{.StartTime}}",
|
||||
"duration": "{{.Duration}}"
|
||||
"start_time": "{{.StartTime | jsonString}}",
|
||||
"duration": "{{.Duration | jsonString}}"
|
||||
},
|
||||
"timestamp": "{{.Timestamp}}",
|
||||
"timestamp": "{{.Timestamp | jsonString}}",
|
||||
"source": "pulse-monitoring"
|
||||
}`,
|
||||
ResolvedPayloadTemplate: `{
|
||||
"event": "resolved",
|
||||
"alert": {
|
||||
"id": "{{.ID}}",
|
||||
"type": "{{.Type}}",
|
||||
"resource_name": "{{.ResourceName}}",
|
||||
"node": "{{.Node}}",
|
||||
"message": "{{.Message}}",
|
||||
"start_time": "{{.StartTime}}",
|
||||
"duration": "{{.Duration}}"
|
||||
"id": "{{.ID | jsonString}}",
|
||||
"type": "{{.Type | jsonString}}",
|
||||
"resource_name": "{{.ResourceName | jsonString}}",
|
||||
"node": "{{.Node | jsonString}}",
|
||||
"message": "{{.Message | jsonString}}",
|
||||
"start_time": "{{.StartTime | jsonString}}",
|
||||
"duration": "{{.Duration | jsonString}}"
|
||||
},
|
||||
"resolved_at": "{{.ResolvedAt}}",
|
||||
"timestamp": "{{.Timestamp}}",
|
||||
"resolved_at": "{{.ResolvedAt | jsonString}}",
|
||||
"timestamp": "{{.Timestamp | jsonString}}",
|
||||
"source": "pulse-monitoring"
|
||||
}`,
|
||||
Instructions: "Configure with your custom webhook endpoint",
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue