feat: implement webhook notifications for alerts

- Add comprehensive webhook support for Discord, Slack, Teams
- Rich embeds with color-coded severity and inline fields
- Webhook configuration UI with test functionality
- Dual payload format (Discord embeds + Slack attachments)
- Error handling with timeout and proper HTTP responses
- Test webhook endpoint with sample alert data

🤖 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-01 21:16:47 +01:00
parent 2c61e09e76
commit 964db89507
3 changed files with 375 additions and 5 deletions
+125 -4
View File
@@ -2,6 +2,7 @@ const EventEmitter = require('events');
const fs = require('fs').promises;
const path = require('path');
const nodemailer = require('nodemailer');
const axios = require('axios');
const customThresholdManager = require('./customThresholds');
class AlertManager extends EventEmitter {
@@ -1134,15 +1135,135 @@ This alert was generated by Pulse monitoring system.
}
/**
* Send webhook notification (placeholder for future implementation)
* Send webhook notification
*/
async sendWebhookNotification(channel, alert) {
if (!channel.config.url) {
throw new Error('Webhook URL not configured');
}
// Placeholder for webhook implementation
console.log(`[WEBHOOK] Would send to: ${channel.config.url}`);
const severityEmoji = {
'info': '💙',
'warning': '⚠️',
'critical': '🚨'
};
const payload = {
timestamp: new Date(alert.timestamp).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: [{
title: `${severityEmoji[alert.rule.severity] || '📢'} ${alert.rule.name}`,
description: alert.rule.description,
color: alert.rule.severity === 'critical' ? 15158332 : // Red
alert.rule.severity === 'warning' ? 15844367 : // Orange
3447003, // Blue
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: 'Status',
value: alert.guest.status,
inline: true
},
{
name: 'Metric',
value: alert.rule.metric.toUpperCase(),
inline: true
},
{
name: 'Current Value',
value: `${alert.value}%`,
inline: true
},
{
name: 'Threshold',
value: `${alert.threshold}%`,
inline: true
}
],
footer: {
text: 'Pulse Monitoring System'
},
timestamp: new Date(alert.timestamp).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
},
{
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(alert.timestamp / 1000)
}]
};
// Set appropriate headers
const headers = {
'Content-Type': 'application/json',
'User-Agent': 'Pulse-Monitoring/1.0',
...channel.config.headers
};
try {
const response = await axios.post(channel.config.url, payload, {
headers,
timeout: 10000, // 10 second timeout
maxRedirects: 3
});
console.log(`[WEBHOOK] Alert sent to: ${channel.config.url} (${response.status})`);
} catch (error) {
if (error.response) {
throw new Error(`Webhook failed: ${error.response.status} ${error.response.statusText}`);
} else if (error.request) {
throw new Error(`Webhook failed: No response from ${channel.config.url}`);
} else {
throw new Error(`Webhook failed: ${error.message}`);
}
}
}
destroy() {
+139
View File
@@ -861,6 +861,145 @@ Pulse Monitoring System
}
});
// Test webhook endpoint
app.post('/api/test-webhook', async (req, res) => {
try {
const { url, enabled } = req.body;
if (!url) {
return res.status(400).json({
success: false,
error: 'Webhook URL is required for testing'
});
}
// 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',
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: '🧪'
},
// 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
},
{
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)
}]
};
// Send test webhook
const response = await axios.post(url, testPayload, {
headers: {
'Content-Type': 'application/json',
'User-Agent': 'Pulse-Monitoring/1.0'
},
timeout: 10000, // 10 second timeout
maxRedirects: 3
});
console.log(`[WEBHOOK TEST] Test webhook sent successfully to: ${url} (${response.status})`);
res.json({
success: true,
message: 'Test webhook sent successfully!',
status: response.status
});
} catch (error) {
console.error('[WEBHOOK TEST] Failed to send test webhook:', error);
let errorMessage = 'Failed to send test webhook';
if (error.response) {
errorMessage = `Webhook failed: ${error.response.status} ${error.response.statusText}`;
} else if (error.request) {
errorMessage = `Webhook failed: No response from ${url}`;
} else {
errorMessage = `Webhook failed: ${error.message}`;
}
res.status(400).json({
success: false,
error: errorMessage
});
}
});
// Global error handler for unhandled API errors
app.use((err, req, res, next) => {
console.error('Unhandled API error:', err);
+111 -1
View File
@@ -186,8 +186,9 @@ PulseApp.ui.settings = (() => {
} else if (activeTab === 'alerts') {
// Load threshold configurations when alerts tab is opened
loadThresholdConfigurations();
// Setup email test button
// Setup email and webhook test buttons
setupEmailTestButton();
setupWebhookTestButton();
}
}
@@ -540,6 +541,55 @@ PulseApp.ui.settings = (() => {
</div>
</div>
<!-- Webhook Notifications -->
<div class="bg-gray-50 dark:bg-gray-900 rounded-lg p-4">
<h3 class="text-lg font-semibold text-gray-800 dark:text-gray-200 mb-4">Webhook Notifications</h3>
<p class="text-sm text-gray-600 dark:text-gray-400 mb-4">Send alert notifications to Discord, Slack, Teams, or any webhook-compatible service.</p>
<div class="grid grid-cols-1 gap-4 mb-4">
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Webhook URL
</label>
<input type="url" name="WEBHOOK_URL"
value="${alerts.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>
</div>
</div>
<div class="flex items-center space-x-4 mb-4">
<label class="flex items-center">
<input type="checkbox" name="WEBHOOK_ENABLED" ${alerts.webhook?.enabled ? '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>
<button type="button" id="test-webhook-btn"
class="px-3 py-1 bg-purple-600 hover:bg-purple-700 text-white text-xs font-medium rounded transition-colors">
Test Webhook
</button>
</div>
<div class="bg-purple-50 dark:bg-purple-900/20 border border-purple-200 dark:border-purple-800 rounded p-3">
<div class="flex">
<div class="flex-shrink-0">
<svg class="h-4 w-4 text-purple-400" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z" clip-rule="evenodd"></path>
</svg>
</div>
<div class="ml-2">
<p class="text-xs text-purple-700 dark:text-purple-300">
<strong>Popular services:</strong><br>
• <strong>Discord:</strong> Server Settings → Integrations → Webhooks<br>
• <strong>Slack:</strong> Apps → Incoming Webhooks<br>
• <strong>Teams:</strong> Channel → Connectors → Incoming Webhook
</p>
</div>
</div>
</div>
</div>
<!-- Custom Per-VM/LXC Thresholds Section -->
<div class="bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg p-4">
<div class="flex items-start">
@@ -2068,6 +2118,66 @@ PulseApp.ui.settings = (() => {
}
}
// Webhook test functionality
function setupWebhookTestButton() {
const testBtn = document.getElementById('test-webhook-btn');
if (testBtn) {
testBtn.addEventListener('click', async () => {
const originalText = testBtn.textContent;
testBtn.textContent = 'Sending...';
testBtn.disabled = true;
try {
// Get webhook settings from form
const form = document.getElementById('settings-form');
const formData = new FormData(form);
const webhookConfig = {
url: formData.get('WEBHOOK_URL'),
enabled: formData.get('WEBHOOK_ENABLED') === 'on'
};
const response = await fetch('/api/test-webhook', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(webhookConfig)
});
const result = await response.json();
if (result.success) {
testBtn.textContent = '✓ Sent!';
testBtn.className = 'px-3 py-1 bg-green-600 hover:bg-green-700 text-white text-xs font-medium rounded transition-colors';
setTimeout(() => {
testBtn.textContent = originalText;
testBtn.className = 'px-3 py-1 bg-purple-600 hover:bg-purple-700 text-white text-xs font-medium rounded transition-colors';
}, 3000);
} else {
testBtn.textContent = '✗ Failed';
testBtn.className = 'px-3 py-1 bg-red-600 hover:bg-red-700 text-white text-xs font-medium rounded transition-colors';
alert('Test webhook failed: ' + result.error);
setTimeout(() => {
testBtn.textContent = originalText;
testBtn.className = 'px-3 py-1 bg-purple-600 hover:bg-purple-700 text-white text-xs font-medium rounded transition-colors';
}, 3000);
}
} catch (error) {
testBtn.textContent = '✗ Error';
testBtn.className = 'px-3 py-1 bg-red-600 hover:bg-red-700 text-white text-xs font-medium rounded transition-colors';
alert('Error sending test webhook: ' + error.message);
setTimeout(() => {
testBtn.textContent = originalText;
testBtn.className = 'px-3 py-1 bg-purple-600 hover:bg-purple-700 text-white text-xs font-medium rounded transition-colors';
}, 3000);
} finally {
testBtn.disabled = false;
}
});
}
}
// Diagnostics functions
let diagnosticData = null;