mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-09-22 11:13:26 +00:00
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:
+125
-4
@@ -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
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user