fix: webhook configuration persistence and Discord 400 errors

- Add webhook configuration to structured config response in configApi
- Update settings UI to properly display webhook enabled state
- Fix Discord webhook 400 errors by sending platform-specific payloads
- Detect webhook type (Discord/Slack) and send appropriate format
- Preserve webhook settings when saving configuration
- Update both test webhook and alert webhook sending logic

Fixes #119

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
courtmanr@gmail.com
2025-06-03 19:31:07 +01:00
parent 79d5b8b977
commit a419763f9c
4 changed files with 276 additions and 136 deletions
+113 -49
View File
@@ -1249,30 +1249,18 @@ This alert was generated by Pulse monitoring system.
};
const validTimestamp = this.getValidTimestamp(alert);
const payload = {
timestamp: new Date(validTimestamp).toISOString(),
alert: {
id: alert.id,
rule: {
name: alert.rule.name,
description: alert.rule.description,
severity: alert.rule.severity,
metric: alert.rule.metric
},
guest: {
name: alert.guest.name,
id: alert.guest.id,
type: alert.guest.type,
node: alert.guest.node,
status: alert.guest.status
},
value: alert.value,
threshold: alert.threshold,
emoji: severityEmoji[alert.rule.severity] || '📢'
},
// Discord/Slack compatible format
embeds: [{
// Detect webhook type based on URL
const url = channel.config.url;
const isDiscord = url.includes('discord.com/api/webhooks') || url.includes('discordapp.com/api/webhooks');
const isSlack = url.includes('slack.com/') || url.includes('hooks.slack.com');
let payload;
if (isDiscord) {
// Discord-specific format
payload = {
embeds: [{
title: `${severityEmoji[alert.rule.severity] || '📢'} ${alert.rule.name}`,
description: alert.rule.description,
color: alert.rule.severity === 'critical' ? 15158332 : // Red
@@ -1314,33 +1302,109 @@ This alert was generated by Pulse monitoring system.
text: 'Pulse Monitoring System'
},
timestamp: new Date(validTimestamp).toISOString()
}],
// Slack compatible format
text: `${severityEmoji[alert.rule.severity] || '📢'} *${alert.rule.name}*`,
attachments: [{
color: alert.rule.severity === 'critical' ? 'danger' :
alert.rule.severity === 'warning' ? 'warning' : 'good',
fields: [
{
title: 'VM/LXC',
value: `${alert.guest.name} (${alert.guest.type} ${alert.guest.id})`,
short: true
}]
};
} else if (isSlack) {
// Slack-specific format
payload = {
text: `${severityEmoji[alert.rule.severity] || '📢'} *${alert.rule.name}*`,
attachments: [{
color: alert.rule.severity === 'critical' ? 'danger' :
alert.rule.severity === 'warning' ? 'warning' : 'good',
fields: [
{
title: 'VM/LXC',
value: `${alert.guest.name} (${alert.guest.type} ${alert.guest.id})`,
short: true
},
{
title: 'Node',
value: alert.guest.node,
short: true
},
{
title: 'Metric',
value: `${alert.rule.metric.toUpperCase()}: ${alert.value}% (threshold: ${alert.threshold}%)`,
short: false
}
],
footer: 'Pulse Monitoring',
ts: Math.floor(validTimestamp / 1000)
}]
};
} else {
// Generic webhook format with all fields (backward compatibility)
payload = {
timestamp: new Date(validTimestamp).toISOString(),
alert: {
id: alert.id,
rule: {
name: alert.rule.name,
description: alert.rule.description,
severity: alert.rule.severity,
metric: alert.rule.metric
},
{
title: 'Node',
value: alert.guest.node,
short: true
guest: {
name: alert.guest.name,
id: alert.guest.id,
type: alert.guest.type,
node: alert.guest.node,
status: alert.guest.status
},
{
title: 'Metric',
value: `${alert.rule.metric.toUpperCase()}: ${alert.value}% (threshold: ${alert.threshold}%)`,
short: false
}
],
footer: 'Pulse Monitoring',
ts: Math.floor(validTimestamp / 1000)
}]
};
value: alert.value,
threshold: alert.threshold,
emoji: severityEmoji[alert.rule.severity] || '📢'
},
// Include both formats for generic webhooks
embeds: [{
title: `${severityEmoji[alert.rule.severity] || '📢'} ${alert.rule.name}`,
description: alert.rule.description,
color: alert.rule.severity === 'critical' ? 15158332 :
alert.rule.severity === 'warning' ? 15844367 :
3447003,
fields: [
{
name: 'VM/LXC',
value: `${alert.guest.name} (${alert.guest.type} ${alert.guest.id})`,
inline: true
},
{
name: 'Node',
value: alert.guest.node,
inline: true
},
{
name: 'Metric',
value: `${alert.rule.metric.toUpperCase()}: ${alert.value}% (threshold: ${alert.threshold}%)`,
inline: true
}
],
footer: {
text: 'Pulse Monitoring System'
},
timestamp: new Date(validTimestamp).toISOString()
}],
text: `${severityEmoji[alert.rule.severity] || '📢'} *${alert.rule.name}*`,
attachments: [{
color: alert.rule.severity === 'critical' ? 'danger' :
alert.rule.severity === 'warning' ? 'warning' : 'good',
fields: [
{
title: 'VM/LXC',
value: `${alert.guest.name} (${alert.guest.type} ${alert.guest.id})`,
short: true
},
{
title: 'Metric',
value: `${alert.rule.metric.toUpperCase()}: ${alert.value}% (threshold: ${alert.threshold}%)`,
short: false
}
],
footer: 'Pulse Monitoring',
ts: Math.floor(validTimestamp / 1000)
}]
};
}
// Set appropriate headers
const headers = {
+15
View File
@@ -64,6 +64,10 @@ class ConfigApi {
down: {
enabled: config.ALERT_DOWN_ENABLED !== 'false'
}
},
webhook: {
url: config.WEBHOOK_URL,
enabled: config.WEBHOOK_ENABLED === 'true'
}
}
};
@@ -182,6 +186,17 @@ class ConfigApi {
existingConfig.ALERT_DOWN_ENABLED = alerts.down.enabled ? 'true' : 'false';
}
}
// Webhook settings
if (config.advanced.webhook) {
const webhook = config.advanced.webhook;
if (webhook.url !== undefined) {
existingConfig.WEBHOOK_URL = webhook.url;
}
if (webhook.enabled !== undefined) {
existingConfig.WEBHOOK_ENABLED = webhook.enabled ? 'true' : 'false';
}
}
}
}
+146 -85
View File
@@ -875,94 +875,155 @@ app.post('/api/test-webhook', async (req, res) => {
// Create test webhook payload
const axios = require('axios');
const testPayload = {
timestamp: new Date().toISOString(),
alert: {
id: 'test-alert-' + Date.now(),
rule: {
name: 'Webhook Test Alert',
// Detect webhook type based on URL
const isDiscord = url.includes('discord.com/api/webhooks') || url.includes('discordapp.com/api/webhooks');
const isSlack = url.includes('slack.com/') || url.includes('hooks.slack.com');
let testPayload;
if (isDiscord) {
// Discord-specific format
testPayload = {
embeds: [{
title: '🧪 Webhook Test Alert',
description: 'This is a test alert to verify webhook configuration',
severity: 'info',
metric: 'test'
color: 3447003, // Blue
fields: [
{
name: 'VM/LXC',
value: 'Test-VM (qemu 999)',
inline: true
},
{
name: 'Node',
value: 'test-node',
inline: true
},
{
name: 'Status',
value: 'running',
inline: true
},
{
name: 'Metric',
value: 'TEST',
inline: true
},
{
name: 'Current Value',
value: '75%',
inline: true
},
{
name: 'Threshold',
value: '80%',
inline: true
}
],
footer: {
text: 'Pulse Monitoring System - Test Message'
},
timestamp: new Date().toISOString()
}]
};
} else if (isSlack) {
// Slack-specific format
testPayload = {
text: '🧪 *Webhook Test Alert*',
attachments: [{
color: 'good',
fields: [
{
title: 'VM/LXC',
value: 'Test-VM (qemu 999)',
short: true
},
{
title: 'Node',
value: 'test-node',
short: true
},
{
title: 'Status',
value: 'Webhook configuration test successful!',
short: false
}
],
footer: 'Pulse Monitoring - Test',
ts: Math.floor(Date.now() / 1000)
}]
};
} else {
// Generic webhook format with all fields (backward compatibility)
testPayload = {
timestamp: new Date().toISOString(),
alert: {
id: 'test-alert-' + Date.now(),
rule: {
name: 'Webhook Test Alert',
description: 'This is a test alert to verify webhook configuration',
severity: 'info',
metric: 'test'
},
guest: {
name: 'Test-VM',
id: '999',
type: 'qemu',
node: 'test-node',
status: 'running'
},
value: 75,
threshold: 80,
emoji: '🧪'
},
guest: {
name: 'Test-VM',
id: '999',
type: 'qemu',
node: 'test-node',
status: 'running'
},
value: 75,
threshold: 80,
emoji: '🧪'
},
// Discord/Slack compatible format
embeds: [{
title: '🧪 Webhook Test Alert',
description: 'This is a test alert to verify webhook configuration',
color: 3447003, // Blue
fields: [
{
name: 'VM/LXC',
value: 'Test-VM (qemu 999)',
inline: true
// Include both formats for generic webhooks
embeds: [{
title: '🧪 Webhook Test Alert',
description: 'This is a test alert to verify webhook configuration',
color: 3447003,
fields: [
{
name: 'VM/LXC',
value: 'Test-VM (qemu 999)',
inline: true
},
{
name: 'Node',
value: 'test-node',
inline: true
},
{
name: 'Status',
value: 'running',
inline: true
}
],
footer: {
text: 'Pulse Monitoring System - Test Message'
},
{
name: 'Node',
value: 'test-node',
inline: true
},
{
name: 'Status',
value: 'running',
inline: true
},
{
name: 'Metric',
value: 'TEST',
inline: true
},
{
name: 'Current Value',
value: '75%',
inline: true
},
{
name: 'Threshold',
value: '80%',
inline: true
}
],
footer: {
text: 'Pulse Monitoring System - Test Message'
},
timestamp: new Date().toISOString()
}],
// Slack compatible format
text: '🧪 *Webhook Test Alert*',
attachments: [{
color: 'good',
fields: [
{
title: 'VM/LXC',
value: 'Test-VM (qemu 999)',
short: true
},
{
title: 'Node',
value: 'test-node',
short: true
},
{
title: 'Status',
value: 'Webhook configuration test successful!',
short: false
}
],
footer: 'Pulse Monitoring - Test',
ts: Math.floor(Date.now() / 1000)
}]
};
timestamp: new Date().toISOString()
}],
text: '🧪 *Webhook Test Alert*',
attachments: [{
color: 'good',
fields: [
{
title: 'VM/LXC',
value: 'Test-VM (qemu 999)',
short: true
},
{
title: 'Status',
value: 'Webhook configuration test successful!',
short: false
}
],
footer: 'Pulse Monitoring - Test',
ts: Math.floor(Date.now() / 1000)
}]
};
}
// Send test webhook
const response = await axios.post(url, testPayload, {
+2 -2
View File
@@ -552,7 +552,7 @@ PulseApp.ui.settings = (() => {
Webhook URL
</label>
<input type="url" name="WEBHOOK_URL"
value="${config.WEBHOOK_URL || ''}"
value="${(config.advanced && config.advanced.webhook && config.advanced.webhook.url) || config.WEBHOOK_URL || ''}"
placeholder="https://discord.com/api/webhooks/..."
class="w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">Discord, Slack, Teams, or any webhook endpoint</p>
@@ -561,7 +561,7 @@ PulseApp.ui.settings = (() => {
<div class="flex items-center space-x-4 mb-4">
<label class="flex items-center">
<input type="checkbox" name="WEBHOOK_ENABLED" ${config.WEBHOOK_ENABLED === 'true' ? 'checked' : ''}
<input type="checkbox" name="WEBHOOK_ENABLED" ${(config.advanced && config.advanced.webhook && config.advanced.webhook.enabled) || config.WEBHOOK_ENABLED === 'true' ? 'checked' : ''}
class="mr-2 h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded">
<span class="text-sm text-gray-700 dark:text-gray-300">Enable Webhook Notifications</span>
</label>