mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-09-21 18:53:37 +00:00
feat: implement dynamic threshold alerts with unified rule system
- Add visual threshold-to-alert conversion feature in dashboard filtering UI - Implement compound threshold rules that support multiple metrics with AND logic - Enhance existing AlertManager to handle both single-metric and compound threshold rules - Add dynamic alert rule creation modal with step-by-step configuration - Implement comprehensive rule management (view, enable/disable, delete) - Add real-time evaluation of compound threshold rules in main metrics processing loop - Create compound rule API endpoints integrated with existing alerts system - Add smart threshold summary display and button state management - Support email/webhook notifications for compound threshold rule violations - Maintain backward compatibility with existing single-metric alert rules This bridges the gap between visual filtering and automated alerting, allowing users to convert their threshold filtering criteria into persistent monitoring rules with a seamless user experience. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
+144
-5
@@ -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
|
||||
*/
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -678,6 +678,33 @@
|
||||
</select>
|
||||
</th>
|
||||
</tr>
|
||||
<!-- Alert Rule Creation Row -->
|
||||
<tr id="alert-rule-row" class="hidden">
|
||||
<td colspan="11" class="py-2 px-4 bg-blue-50 dark:bg-blue-900/20 border-t border-blue-200 dark:border-blue-700">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-2">
|
||||
<svg class="h-4 w-4 text-blue-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 17h5l-5 5v-5zm-2-8V7a1 1 0 00-1-1H5a1 1 0 00-1 1v3a1 1 0 001 1h7a1 1 0 001-1z"></path>
|
||||
</svg>
|
||||
<span class="text-sm font-medium text-blue-700 dark:text-blue-300">
|
||||
Create Alert Rule from Current Thresholds
|
||||
</span>
|
||||
<span id="active-thresholds-summary" class="text-xs text-blue-600 dark:text-blue-400"></span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<button id="create-alert-rule-btn"
|
||||
class="px-3 py-1 bg-blue-600 hover:bg-blue-700 text-white text-xs font-medium rounded transition-colors"
|
||||
disabled>
|
||||
Create Alert Rule
|
||||
</button>
|
||||
<button id="view-alert-rules-btn"
|
||||
class="px-3 py-1 bg-gray-500 hover:bg-gray-600 text-white text-xs font-medium rounded transition-colors">
|
||||
View Rules
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-200 dark:divide-gray-700">
|
||||
<tr>
|
||||
|
||||
@@ -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 = `
|
||||
<div id="alert-rule-modal" class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
||||
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-xl max-w-2xl w-full mx-4 max-h-[90vh] overflow-y-auto">
|
||||
<div class="p-6">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-gray-100">
|
||||
Create Alert Rule from Thresholds
|
||||
</h3>
|
||||
<button id="close-alert-modal" class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300">
|
||||
<svg class="h-6 w-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form id="alert-rule-form">
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Rule Name
|
||||
</label>
|
||||
<input type="text" id="rule-name" required
|
||||
placeholder="e.g., High Resource Usage Alert"
|
||||
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100">
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Description
|
||||
</label>
|
||||
<textarea id="rule-description" rows="2"
|
||||
placeholder="Optional description of what this alert monitors"
|
||||
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100"></textarea>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Alert Conditions
|
||||
</label>
|
||||
<div class="bg-gray-50 dark:bg-gray-700 rounded-md p-3">
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400 mb-2">
|
||||
Alert will trigger when ANY guest meets ALL of these conditions:
|
||||
</p>
|
||||
<ul class="space-y-1">
|
||||
${activeThresholds.map(threshold => `
|
||||
<li class="text-sm text-gray-800 dark:text-gray-200">
|
||||
• ${_getThresholdDisplayName(threshold.type)} ≥ ${_formatThresholdValue(threshold)}
|
||||
</li>
|
||||
`).join('')}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Alert Severity
|
||||
</label>
|
||||
<select id="rule-severity" class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100">
|
||||
<option value="warning">Warning</option>
|
||||
<option value="critical">Critical</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end gap-3 mt-6">
|
||||
<button type="button" id="cancel-alert-rule"
|
||||
class="px-4 py-2 text-gray-700 dark:text-gray-300 bg-gray-200 dark:bg-gray-600 rounded-md hover:bg-gray-300 dark:hover:bg-gray-500">
|
||||
Cancel
|
||||
</button>
|
||||
<button type="submit"
|
||||
class="px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-md">
|
||||
Create Alert Rule
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
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 = `
|
||||
<div id="view-rules-modal" class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
||||
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-xl max-w-4xl w-full mx-4 max-h-[90vh] overflow-y-auto">
|
||||
<div class="p-6">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-gray-100">
|
||||
Dynamic Threshold Alert Rules
|
||||
</h3>
|
||||
<button id="close-rules-modal" class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300">
|
||||
<svg class="h-6 w-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
${rules.length === 0 ? `
|
||||
<div class="text-center py-8">
|
||||
<svg class="h-12 w-12 text-gray-400 mx-auto mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5H7a2 2 0 00-2 2v10a2 2 0 002 2h8a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2"></path>
|
||||
</svg>
|
||||
<p class="text-gray-500 dark:text-gray-400 mb-2">No dynamic threshold rules created yet</p>
|
||||
<p class="text-sm text-gray-400 dark:text-gray-500">Set some thresholds above and click "Create Alert Rule" to get started!</p>
|
||||
</div>
|
||||
` : `
|
||||
<div class="space-y-4">
|
||||
${rules.map(rule => _formatRuleCard(rule)).join('')}
|
||||
</div>
|
||||
`}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
document.body.insertAdjacentHTML('beforeend', modalHtml);
|
||||
_setupViewRulesModalListeners();
|
||||
}
|
||||
|
||||
function _formatRuleCard(rule) {
|
||||
const thresholdsList = rule.thresholds.map(t =>
|
||||
`<span class="inline-block bg-gray-100 dark:bg-gray-700 px-2 py-1 text-xs rounded mr-2 mb-1">
|
||||
${_getThresholdDisplayName(t.type)} ≥ ${_formatThresholdValue(t)}
|
||||
</span>`
|
||||
).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 `
|
||||
<div class="border border-gray-200 dark:border-gray-600 rounded-lg p-4">
|
||||
<div class="flex items-start justify-between mb-3">
|
||||
<div class="flex-1">
|
||||
<div class="flex items-center gap-2 mb-2">
|
||||
<h4 class="font-medium text-gray-900 dark:text-gray-100">${rule.name}</h4>
|
||||
<span class="px-2 py-1 text-xs font-medium rounded ${severityColor}">
|
||||
${rule.severity.toUpperCase()}
|
||||
</span>
|
||||
<span class="px-2 py-1 text-xs font-medium rounded ${rule.enabled ? 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200' : 'bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-200'}">
|
||||
${rule.enabled ? 'ENABLED' : 'DISABLED'}
|
||||
</span>
|
||||
</div>
|
||||
${rule.description ? `<p class="text-sm text-gray-600 dark:text-gray-400 mb-2">${rule.description}</p>` : ''}
|
||||
<div class="text-xs text-gray-500 dark:text-gray-400 mb-2">
|
||||
Created: ${createdDate} • ID: ${rule.id}
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<button onclick="PulseApp.ui.thresholds.toggleRule('${rule.id}', ${!rule.enabled})"
|
||||
class="px-2 py-1 text-xs bg-blue-600 hover:bg-blue-700 text-white rounded">
|
||||
${rule.enabled ? 'Disable' : 'Enable'}
|
||||
</button>
|
||||
<button onclick="PulseApp.ui.thresholds.deleteRule('${rule.id}')"
|
||||
class="px-2 py-1 text-xs bg-red-600 hover:bg-red-700 text-white rounded">
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-sm text-gray-700 dark:text-gray-300 mb-2">Alert triggers when ANY guest meets ALL conditions:</p>
|
||||
<div class="flex flex-wrap">
|
||||
${thresholdsList}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
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
|
||||
};
|
||||
})();
|
||||
|
||||
Reference in New Issue
Block a user