From 964db89507693723a4ec254294b402ceebf38be0 Mon Sep 17 00:00:00 2001 From: "courtmanr@gmail.com" Date: Sun, 1 Jun 2025 21:16:47 +0100 Subject: [PATCH] feat: implement webhook notifications for alerts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- server/alertManager.js | 129 +++++++++++++++++++++++++++++++- server/index.js | 139 +++++++++++++++++++++++++++++++++++ src/public/js/ui/settings.js | 112 +++++++++++++++++++++++++++- 3 files changed, 375 insertions(+), 5 deletions(-) diff --git a/server/alertManager.js b/server/alertManager.js index 08829afb2..e4a5dba1f 100644 --- a/server/alertManager.js +++ b/server/alertManager.js @@ -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() { diff --git a/server/index.js b/server/index.js index 39b5e4b50..3d6d62f81 100644 --- a/server/index.js +++ b/server/index.js @@ -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); diff --git a/src/public/js/ui/settings.js b/src/public/js/ui/settings.js index 8fe1c51be..17dd1fb14 100644 --- a/src/public/js/ui/settings.js +++ b/src/public/js/ui/settings.js @@ -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 = (() => { + +
+

Webhook Notifications

+

Send alert notifications to Discord, Slack, Teams, or any webhook-compatible service.

+ +
+
+ + +

Discord, Slack, Teams, or any webhook endpoint

+
+
+ +
+ + +
+ +
+
+
+ + + +
+
+

+ Popular services:
+ โ€ข Discord: Server Settings โ†’ Integrations โ†’ Webhooks
+ โ€ข Slack: Apps โ†’ Incoming Webhooks
+ โ€ข Teams: Channel โ†’ Connectors โ†’ Incoming Webhook +

+
+
+
+
+
@@ -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;