diff --git a/server/alertManager.js b/server/alertManager.js index 103ecbefe..da604f4ca 100644 --- a/server/alertManager.js +++ b/server/alertManager.js @@ -282,11 +282,19 @@ class AlertManager extends EventEmitter { const timestamp = Date.now(); guests.forEach(guest => { + // Evaluate all alert rules (both single-metric and compound threshold rules) this.alertRules.forEach(rule => { if (this.isRuleSuppressed(rule.id, guest)) return; const alertKey = `${rule.id}_${guest.endpointId}_${guest.node}_${guest.vmid}`; - this.evaluateRule(rule, guest, metrics, alertKey, timestamp); + + if (rule.type === 'compound_threshold' && rule.thresholds) { + // Handle compound threshold rules + this.evaluateCompoundThresholdRule(rule, guest, metrics, alertKey, timestamp); + } else { + // Handle single-metric rules + this.evaluateRule(rule, guest, metrics, alertKey, timestamp); + } }); }); } @@ -904,26 +912,40 @@ class AlertManager extends EventEmitter { } addRule(rule) { - if (!rule.id || !rule.name || !rule.metric) { - throw new Error('Rule must have id, name, and metric'); + // Support both single-metric rules and compound threshold rules + const isCompoundRule = rule.thresholds && Array.isArray(rule.thresholds) && rule.thresholds.length > 0; + + if (!rule.name) { + throw new Error('Rule must have a name'); } + if (!isCompoundRule && !rule.metric) { + throw new Error('Single-metric rule must have a metric, or provide thresholds for compound rule'); + } + + const ruleId = rule.id || (isCompoundRule ? + `compound_${Date.now()}_${Math.random().toString(36).substr(2, 9)}` : + `rule_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`); + // Set defaults for new rules const fullRule = { + id: ruleId, condition: 'greater_than', duration: 300000, severity: 'warning', enabled: true, tags: [], - group: 'custom', + group: isCompoundRule ? 'compound_threshold' : 'custom', escalationTime: 900000, autoResolve: true, suppressionTime: 300000, notificationChannels: ['default'], + type: isCompoundRule ? 'compound_threshold' : 'single_metric', ...rule }; - this.alertRules.set(rule.id, fullRule); + this.alertRules.set(ruleId, fullRule); + console.log(`[AlertManager] Added ${isCompoundRule ? 'compound threshold' : 'single-metric'} rule: ${fullRule.name} (${ruleId})`); this.emit('ruleAdded', fullRule); return fullRule; } @@ -969,6 +991,123 @@ class AlertManager extends EventEmitter { this.emit('rulesRefreshed', { activeRules: nowActiveRules.size, disabledRules }); } + + evaluateCompoundThresholdRule(rule, guest, metrics, alertKey, timestamp) { + const guestMetrics = metrics[guest.vmid] || metrics[guest.name]; + if (!guestMetrics || !guestMetrics.current) return; + + // Check if ALL threshold conditions are met (AND logic) + const thresholdsMet = rule.thresholds.every(threshold => { + return this.evaluateThresholdCondition(threshold, guestMetrics.current, guest); + }); + + const existingAlert = this.activeAlerts.get(alertKey); + + if (thresholdsMet) { + if (!existingAlert) { + // Create new alert + const alert = { + id: alertKey, + ruleId: rule.id, + rule: rule, + guest: guest, + severity: rule.severity, + message: this.formatCompoundThresholdMessage(rule, guestMetrics.current, guest), + timestamp: timestamp, + state: 'firing', + values: this.getCurrentThresholdValues(rule.thresholds, guestMetrics.current, guest) + }; + + this.fireAlert(alert); + } + } else if (existingAlert) { + // Resolve existing alert + this.resolveAlert(alertKey, timestamp, 'Thresholds no longer exceeded'); + } + } + + evaluateThresholdCondition(threshold, currentMetrics, guest) { + let metricValue; + + switch (threshold.type) { + case 'cpu': + metricValue = currentMetrics.cpu; + break; + case 'memory': + metricValue = currentMetrics.memory; + break; + case 'disk': + metricValue = currentMetrics.disk; + break; + case 'diskread': + metricValue = currentMetrics.diskread; + break; + case 'diskwrite': + metricValue = currentMetrics.diskwrite; + break; + case 'netin': + metricValue = currentMetrics.netin; + break; + case 'netout': + metricValue = currentMetrics.netout; + break; + default: + return false; + } + + if (metricValue === undefined || metricValue === null || isNaN(metricValue)) { + return false; + } + + return metricValue >= threshold.value; + } + + formatCompoundThresholdMessage(rule, currentMetrics, guest) { + const conditions = rule.thresholds.map(threshold => { + const value = this.getThresholdCurrentValue(threshold, currentMetrics); + const displayName = this.getThresholdDisplayName(threshold.type); + const unit = ['cpu', 'memory', 'disk'].includes(threshold.type) ? '%' : ' bytes/s'; + + return `${displayName}: ${value}${unit} (≥ ${threshold.value}${unit})`; + }).join(', '); + + return `Dynamic threshold rule "${rule.name}" triggered for ${guest.name}: ${conditions}`; + } + + getCurrentThresholdValues(thresholds, currentMetrics, guest) { + const values = {}; + thresholds.forEach(threshold => { + values[threshold.type] = this.getThresholdCurrentValue(threshold, currentMetrics); + }); + return values; + } + + getThresholdCurrentValue(threshold, currentMetrics) { + switch (threshold.type) { + case 'cpu': return currentMetrics.cpu || 0; + case 'memory': return currentMetrics.memory || 0; + case 'disk': return currentMetrics.disk || 0; + case 'diskread': return currentMetrics.diskread || 0; + case 'diskwrite': return currentMetrics.diskwrite || 0; + case 'netin': return currentMetrics.netin || 0; + case 'netout': return currentMetrics.netout || 0; + default: return 0; + } + } + + getThresholdDisplayName(type) { + const names = { + 'cpu': 'CPU', + 'memory': 'Memory', + 'disk': 'Disk', + 'diskread': 'Disk Read', + 'diskwrite': 'Disk Write', + 'netin': 'Network In', + 'netout': 'Network Out' + }; + return names[type] || type; + } + /** * Clean up active alerts for a specific rule type */ diff --git a/server/index.js b/server/index.js index 7fc8379f5..8d6077320 100644 --- a/server/index.js +++ b/server/index.js @@ -507,6 +507,18 @@ app.delete('/api/alerts/rules/:id', (req, res) => { } }); +// Enhanced alerts/rules endpoints to handle compound threshold rules +app.get('/api/alerts/compound-rules', (req, res) => { + try { + const allRules = stateManager.alertManager.getRules(); + const compoundRules = allRules.filter(rule => rule.type === 'compound_threshold'); + res.json({ success: true, rules: compoundRules }); + } catch (error) { + console.error("Error fetching compound threshold rules:", error); + res.status(500).json({ error: "Failed to fetch compound threshold rules" }); + } +}); + // Version API endpoint app.get('/api/version', async (req, res) => { diff --git a/src/public/index.html b/src/public/index.html index 7fd5cdb8c..b10ccd04b 100644 --- a/src/public/index.html +++ b/src/public/index.html @@ -678,6 +678,33 @@ + + + +
+
+ + + + + Create Alert Rule from Current Thresholds + + +
+
+ + +
+
+ + diff --git a/src/public/js/ui/thresholds.js b/src/public/js/ui/thresholds.js index deda3a559..64abdf9e7 100644 --- a/src/public/js/ui/thresholds.js +++ b/src/public/js/ui/thresholds.js @@ -2,16 +2,24 @@ PulseApp.ui = PulseApp.ui || {}; PulseApp.ui.thresholds = (() => { let thresholdRow = null; + let alertRuleRow = null; let toggleThresholdsButton = null; let thresholdBadge = null; + let createAlertRuleBtn = null; + let viewAlertRulesBtn = null; + let activeThresholdsSummary = null; let sliders = {}; let thresholdSelects = {}; let isDraggingSlider = false; function init() { thresholdRow = document.getElementById('threshold-slider-row'); + alertRuleRow = document.getElementById('alert-rule-row'); toggleThresholdsButton = document.getElementById('toggle-thresholds-checkbox'); thresholdBadge = document.getElementById('threshold-count-badge'); + createAlertRuleBtn = document.getElementById('create-alert-rule-btn'); + viewAlertRulesBtn = document.getElementById('view-alert-rules-btn'); + activeThresholdsSummary = document.getElementById('active-thresholds-summary'); sliders = { cpu: document.getElementById('threshold-slider-cpu'), @@ -41,6 +49,7 @@ PulseApp.ui.thresholds = (() => { _setupSliderListeners(); _setupSelectListeners(); _setupDragEndListeners(); + _setupAlertRuleListeners(); } function applyInitialThresholdUI() { @@ -110,6 +119,355 @@ PulseApp.ui.thresholds = (() => { document.addEventListener('touchend', _handleThresholdDragEnd); } + function _setupAlertRuleListeners() { + if (createAlertRuleBtn) { + createAlertRuleBtn.addEventListener('click', _handleCreateAlertRule); + } + + if (viewAlertRulesBtn) { + viewAlertRulesBtn.addEventListener('click', _handleViewAlertRules); + } + } + + function _handleCreateAlertRule() { + const thresholdState = PulseApp.state.getThresholdState(); + const activeThresholds = _getActiveThresholds(thresholdState); + + if (activeThresholds.length === 0) { + alert('Please set at least one threshold to create an alert rule.'); + return; + } + + // Show modal for creating alert rule + _showCreateAlertRuleModal(activeThresholds); + } + + function _handleViewAlertRules() { + // Show modal for viewing/managing existing alert rules + _showViewAlertRulesModal(); + } + + function _showCreateAlertRuleModal(activeThresholds) { + // Create a modal for alert rule creation + const modalHtml = ` +
+
+
+
+

+ Create Alert Rule from Thresholds +

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

+ Alert will trigger when ANY guest meets ALL of these conditions: +

+
    + ${activeThresholds.map(threshold => ` +
  • + • ${_getThresholdDisplayName(threshold.type)} ≥ ${_formatThresholdValue(threshold)} +
  • + `).join('')} +
+
+
+ +
+ + +
+
+ +
+ + +
+
+
+
+
+ `; + + document.body.insertAdjacentHTML('beforeend', modalHtml); + _setupAlertRuleModalListeners(activeThresholds); + } + + function _formatThresholdValue(threshold) { + if (['cpu', 'memory', 'disk'].includes(threshold.type)) { + return `${threshold.value}%`; + } else { + return _formatBytesThreshold(threshold.value); + } + } + + function _setupAlertRuleModalListeners(activeThresholds) { + const modal = document.getElementById('alert-rule-modal'); + const closeBtn = document.getElementById('close-alert-modal'); + const cancelBtn = document.getElementById('cancel-alert-rule'); + const form = document.getElementById('alert-rule-form'); + + const closeModal = () => { + modal.remove(); + }; + + closeBtn.addEventListener('click', closeModal); + cancelBtn.addEventListener('click', closeModal); + + modal.addEventListener('click', (e) => { + if (e.target === modal) closeModal(); + }); + + form.addEventListener('submit', (e) => { + e.preventDefault(); + _handleAlertRuleSubmit(activeThresholds); + closeModal(); + }); + } + + async function _handleAlertRuleSubmit(activeThresholds) { + const ruleName = document.getElementById('rule-name').value; + const ruleDescription = document.getElementById('rule-description').value; + const ruleSeverity = document.getElementById('rule-severity').value; + + const alertRule = { + name: ruleName, + description: ruleDescription, + severity: ruleSeverity, + thresholds: activeThresholds, + enabled: true + }; + + try { + const response = await fetch('/api/alerts/rules', { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify(alertRule) + }); + + const result = await response.json(); + + if (response.ok && result.success) { + alert(`✅ Alert rule "${ruleName}" created successfully!\n\nIt will monitor for guests meeting ALL of these conditions:\n${activeThresholds.map(t => `• ${_getThresholdDisplayName(t.type)} ≥ ${_formatThresholdValue(t)}`).join('\n')}\n\nYou'll receive email notifications when any guest meets these criteria.`); + } else { + throw new Error(result.error || 'Failed to create alert rule'); + } + } catch (error) { + console.error('Error creating alert rule:', error); + alert(`❌ Failed to create alert rule: ${error.message}`); + } + } + + async function _showViewAlertRulesModal() { + try { + const response = await fetch('/api/alerts/compound-rules'); + const result = await response.json(); + + if (!response.ok || !result.success) { + throw new Error(result.error || 'Failed to fetch alert rules'); + } + + const rules = result.rules || []; + _displayAlertRulesModal(rules); + } catch (error) { + console.error('Error fetching alert rules:', error); + alert(`❌ Failed to fetch alert rules: ${error.message}`); + } + } + + function _displayAlertRulesModal(rules) { + const modalHtml = ` +
+
+
+
+

+ Dynamic Threshold Alert Rules +

+ +
+ + ${rules.length === 0 ? ` +
+ + + +

No dynamic threshold rules created yet

+

Set some thresholds above and click "Create Alert Rule" to get started!

+
+ ` : ` +
+ ${rules.map(rule => _formatRuleCard(rule)).join('')} +
+ `} +
+
+
+ `; + + document.body.insertAdjacentHTML('beforeend', modalHtml); + _setupViewRulesModalListeners(); + } + + function _formatRuleCard(rule) { + const thresholdsList = rule.thresholds.map(t => + ` + ${_getThresholdDisplayName(t.type)} ≥ ${_formatThresholdValue(t)} + ` + ).join(''); + + const createdDate = new Date(rule.createdAt).toLocaleDateString(); + const severityColor = rule.severity === 'critical' ? 'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200' : 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200'; + + return ` +
+
+
+
+

${rule.name}

+ + ${rule.severity.toUpperCase()} + + + ${rule.enabled ? 'ENABLED' : 'DISABLED'} + +
+ ${rule.description ? `

${rule.description}

` : ''} +
+ Created: ${createdDate} • ID: ${rule.id} +
+
+
+ + +
+
+
+

Alert triggers when ANY guest meets ALL conditions:

+
+ ${thresholdsList} +
+
+
+ `; + } + + function _setupViewRulesModalListeners() { + const modal = document.getElementById('view-rules-modal'); + const closeBtn = document.getElementById('close-rules-modal'); + + const closeModal = () => { + modal.remove(); + }; + + closeBtn.addEventListener('click', closeModal); + modal.addEventListener('click', (e) => { + if (e.target === modal) closeModal(); + }); + } + + async function toggleRule(ruleId, enabled) { + try { + const response = await fetch(`/api/alerts/rules/${ruleId}`, { + method: 'PUT', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ enabled }) + }); + + const result = await response.json(); + + if (response.ok && result.success) { + // Refresh the modal + document.getElementById('view-rules-modal').remove(); + _showViewAlertRulesModal(); + } else { + throw new Error(result.error || 'Failed to update rule'); + } + } catch (error) { + console.error('Error updating rule:', error); + alert(`❌ Failed to update rule: ${error.message}`); + } + } + + async function deleteRule(ruleId) { + if (!confirm('Are you sure you want to delete this alert rule? This action cannot be undone.')) { + return; + } + + try { + const response = await fetch(`/api/alerts/rules/${ruleId}`, { + method: 'DELETE' + }); + + const result = await response.json(); + + if (response.ok && result.success) { + // Refresh the modal + document.getElementById('view-rules-modal').remove(); + _showViewAlertRulesModal(); + } else { + throw new Error(result.error || 'Failed to delete rule'); + } + } catch (error) { + console.error('Error deleting rule:', error); + alert(`❌ Failed to delete rule: ${error.message}`); + } + } + function updateThreshold(type, value) { PulseApp.state.setThresholdValue(type, value); @@ -130,6 +488,11 @@ PulseApp.ui.thresholds = (() => { toggleThresholdsButton.checked = isVisible; } } + + // Show alert rule row only when threshold row is visible + if (alertRuleRow) { + alertRuleRow.classList.toggle('hidden', !isVisible); + } } function _updateThresholdHeaderStyles(thresholdState) { @@ -163,6 +526,7 @@ PulseApp.ui.thresholds = (() => { const thresholdState = PulseApp.state.getThresholdState(); const activeCount = _updateThresholdHeaderStyles(thresholdState); + const activeThresholds = _getActiveThresholds(thresholdState); if (activeCount > 0) { thresholdBadge.textContent = activeCount; @@ -170,6 +534,81 @@ PulseApp.ui.thresholds = (() => { } else { thresholdBadge.classList.add('hidden'); } + + // Update alert rule button state and summary + _updateAlertRuleUI(activeThresholds, activeCount); + } + + function _getActiveThresholds(thresholdState) { + const active = []; + for (const type in thresholdState) { + if (thresholdState[type].value > 0) { + active.push({ + type: type, + value: thresholdState[type].value + }); + } + } + return active; + } + + function _updateAlertRuleUI(activeThresholds, activeCount) { + // Update button state + if (createAlertRuleBtn) { + createAlertRuleBtn.disabled = activeCount === 0; + if (activeCount === 0) { + createAlertRuleBtn.classList.add('opacity-50', 'cursor-not-allowed'); + } else { + createAlertRuleBtn.classList.remove('opacity-50', 'cursor-not-allowed'); + } + } + + // Update summary text + if (activeThresholdsSummary) { + if (activeCount > 0) { + const summaryText = _formatThresholdSummary(activeThresholds); + activeThresholdsSummary.textContent = `(${summaryText})`; + } else { + activeThresholdsSummary.textContent = '(Set thresholds above to enable)'; + } + } + } + + function _formatThresholdSummary(activeThresholds) { + const formatted = activeThresholds.map(threshold => { + const name = _getThresholdDisplayName(threshold.type); + let value; + + if (['cpu', 'memory', 'disk'].includes(threshold.type)) { + value = `${threshold.value}%`; + } else { + value = _formatBytesThreshold(threshold.value); + } + + return `${name} > ${value}`; + }); + + return formatted.join(', '); + } + + function _getThresholdDisplayName(type) { + const names = { + 'cpu': 'CPU', + 'memory': 'Memory', + 'disk': 'Disk', + 'diskread': 'Disk Read', + 'diskwrite': 'Disk Write', + 'netin': 'Net In', + 'netout': 'Net Out' + }; + return names[type] || type; + } + + function _formatBytesThreshold(bytes) { + const mb = bytes / (1024 * 1024); + if (mb >= 100) return `${Math.round(mb)}MB/s`; + if (mb >= 10) return `${Math.round(mb)}MB/s`; + return `${Math.round(mb * 10) / 10}MB/s`; } function resetThresholds() { @@ -198,6 +637,8 @@ PulseApp.ui.thresholds = (() => { return { init, resetThresholds, - isThresholdDragInProgress + isThresholdDragInProgress, + toggleRule, + deleteRule }; })();