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
+
+
+
+
+
+
+
+
+ `;
+
+ 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
};
})();