feat: comprehensive alert system improvements

- Fixed alert units display (MB/s vs %) in notifications and UI
- Added missing threshold controls for disk I/O and network metrics
- Redesigned threshold table with active alert indicators
- Added ability to disable node connectivity alerts
- Improved visual distinction between defaults and overrides
- Added keyboard shortcuts for search (type to search, ESC to clear)
- Better threshold management with automatic cleanup of empty overrides
- Enhanced UI with proper units display for all metrics

addresses #336
This commit is contained in:
Pulse Monitor
2025-08-21 10:19:56 +00:00
parent a1c5adb30f
commit 86bc5c32fa
5 changed files with 732 additions and 193 deletions
File diff suppressed because it is too large Load Diff
+10 -1
View File
@@ -56,6 +56,7 @@ interface Override {
node?: string; // Node name (for guests), undefined for nodes themselves
instance?: string;
disabled?: boolean; // Completely disable alerts for this guest
disableConnectivity?: boolean; // For nodes - disable offline/connectivity alerts
thresholds: {
cpu?: number;
memory?: number;
@@ -159,6 +160,7 @@ export function Alerts() {
name: node.name,
type: 'node',
resourceType: 'Node',
disableConnectivity: thresholds.disableConnectivity || false,
thresholds: extractTriggerValues(thresholds)
});
} else {
@@ -188,7 +190,11 @@ export function Alerts() {
overridesList.some((newOverride) => {
const existing = currentOverrides.find(o => o.id === newOverride.id);
if (!existing) return true;
return JSON.stringify(newOverride.thresholds) !== JSON.stringify(existing.thresholds);
// Check both thresholds and disableConnectivity for nodes
const thresholdsChanged = JSON.stringify(newOverride.thresholds) !== JSON.stringify(existing.thresholds);
const connectivityChanged = newOverride.type === 'node' && newOverride.disableConnectivity !== existing.disableConnectivity;
const disabledChanged = newOverride.type === 'guest' && newOverride.disabled !== existing.disabled;
return thresholdsChanged || connectivityChanged || disabledChanged;
});
if (hasChanged) {
@@ -335,6 +341,8 @@ export function Alerts() {
const extractTriggerValues = (thresholds: AlertThresholds): Record<string, number> => {
const result: Record<string, number> = {};
Object.entries(thresholds).forEach(([key, value]) => {
// Skip non-threshold fields
if (key === 'disabled' || key === 'disableConnectivity') return;
result[key] = getTriggerValue(value);
});
return result;
@@ -850,6 +858,7 @@ function ThresholdsTab(props: ThresholdsTabProps) {
timeThreshold={props.timeThreshold}
setTimeThreshold={props.setTimeThreshold}
setHasUnsavedChanges={props.setHasUnsavedChanges}
activeAlerts={props.activeAlerts}
/>
);
}
+22 -8
View File
@@ -59,14 +59,15 @@ type HysteresisThreshold struct {
// ThresholdConfig represents threshold configuration
type ThresholdConfig struct {
Disabled bool `json:"disabled,omitempty"` // Completely disable alerts for this guest
CPU *HysteresisThreshold `json:"cpu,omitempty"`
Memory *HysteresisThreshold `json:"memory,omitempty"`
Disk *HysteresisThreshold `json:"disk,omitempty"`
DiskRead *HysteresisThreshold `json:"diskRead,omitempty"`
DiskWrite *HysteresisThreshold `json:"diskWrite,omitempty"`
NetworkIn *HysteresisThreshold `json:"networkIn,omitempty"`
NetworkOut *HysteresisThreshold `json:"networkOut,omitempty"`
Disabled bool `json:"disabled,omitempty"` // Completely disable alerts for this guest
DisableConnectivity bool `json:"disableConnectivity,omitempty"` // Disable node offline/connectivity alerts
CPU *HysteresisThreshold `json:"cpu,omitempty"`
Memory *HysteresisThreshold `json:"memory,omitempty"`
Disk *HysteresisThreshold `json:"disk,omitempty"`
DiskRead *HysteresisThreshold `json:"diskRead,omitempty"`
DiskWrite *HysteresisThreshold `json:"diskWrite,omitempty"`
NetworkIn *HysteresisThreshold `json:"networkIn,omitempty"`
NetworkOut *HysteresisThreshold `json:"networkOut,omitempty"`
// Legacy fields for backward compatibility
CPULegacy *float64 `json:"cpuLegacy,omitempty"`
MemoryLegacy *float64 `json:"memoryLegacy,omitempty"`
@@ -872,6 +873,19 @@ func (m *Manager) checkNodeOffline(node models.Node) {
m.mu.Lock()
defer m.mu.Unlock()
// Check if node connectivity alerts are disabled
if override, exists := m.config.Overrides[node.ID]; exists && override.DisableConnectivity {
// Node connectivity alerts are disabled, clear any existing alert and return
if _, alertExists := m.activeAlerts[alertID]; alertExists {
delete(m.activeAlerts, alertID)
log.Debug().
Str("node", node.Name).
Msg("Node offline alert cleared (connectivity alerts disabled)")
}
delete(m.nodeOfflineCount, node.ID)
return
}
// Check if alert already exists
if _, exists := m.activeAlerts[alertID]; exists {
// Alert already exists, just update time
+27 -11
View File
@@ -93,11 +93,11 @@ func singleAlertTemplate(alert *alerts.Alert) (subject, htmlBody, textBody strin
<div class="metrics">
<div class="metric">
<div class="metric-label">Current Value</div>
<div class="metric-value">%.1f%%</div>
<div class="metric-value">%s</div>
</div>
<div class="metric">
<div class="metric-label">Threshold</div>
<div class="metric-value">%.0f%%</div>
<div class="metric-value">%s</div>
</div>
</div>
@@ -139,8 +139,8 @@ func singleAlertTemplate(alert *alerts.Alert) (subject, htmlBody, textBody strin
alert.Level,
alert.ResourceName,
alert.Message,
alert.Value,
alert.Threshold,
formatMetricValue(alert.Type, alert.Value),
formatMetricThreshold(alert.Type, alert.Threshold),
alert.ResourceID,
alertType,
alert.Node,
@@ -156,7 +156,7 @@ func singleAlertTemplate(alert *alerts.Alert) (subject, htmlBody, textBody strin
Resource: %s (%s)
Type: %s
Current Value: %.1f%% (Threshold: %.0f%%)
Current Value: %s (Threshold: %s)
Message: %s
Details:
@@ -172,8 +172,8 @@ View alerts and configure settings in your Pulse dashboard.`,
alert.ResourceName,
alert.ResourceID,
alert.Type,
alert.Value,
alert.Threshold,
formatMetricValue(alert.Type, alert.Value),
formatMetricThreshold(alert.Type, alert.Threshold),
alert.Message,
alert.Node,
alert.Instance,
@@ -227,8 +227,8 @@ func groupedAlertTemplate(alertList []*alerts.Alert) (subject, htmlBody, textBod
<span style="color: %s; font-weight: 500; text-transform: uppercase; font-size: 12px;">%s</span>
</td>
<td style="padding: 12px; border-bottom: 1px solid #e9ecef; text-align: right;">
<div style="font-weight: 500;">%.1f%%</div>
<div style="font-size: 12px; color: #666;">of %.0f%%</div>
<div style="font-weight: 500;">%s</div>
<div style="font-size: 12px; color: #666;">of %s</div>
</td>
<td style="padding: 12px; border-bottom: 1px solid #e9ecef; text-align: right; color: #666; font-size: 12px;">
%s ago
@@ -238,7 +238,7 @@ func groupedAlertTemplate(alertList []*alerts.Alert) (subject, htmlBody, textBod
alert.ResourceName,
alert.Type, alert.Node,
levelColor, alert.Level,
alert.Value, alert.Threshold,
formatMetricValue(alert.Type, alert.Value), formatMetricThreshold(alert.Type, alert.Threshold),
formatDuration(time.Since(alert.StartTime)),
))
}
@@ -344,7 +344,7 @@ func groupedAlertTemplate(alertList []*alerts.Alert) (subject, htmlBody, textBod
for i, alert := range alertList {
textBuilder.WriteString(fmt.Sprintf("\n%d. %s (%s)\n", i+1, alert.ResourceName, alert.ResourceID))
textBuilder.WriteString(fmt.Sprintf(" Level: %s | Type: %s\n", strings.ToUpper(string(alert.Level)), alert.Type))
textBuilder.WriteString(fmt.Sprintf(" Value: %.1f%% (Threshold: %.0f%%)\n", alert.Value, alert.Threshold))
textBuilder.WriteString(fmt.Sprintf(" Value: %s (Threshold: %s)\n", formatMetricValue(alert.Type, alert.Value), formatMetricThreshold(alert.Type, alert.Threshold)))
textBuilder.WriteString(fmt.Sprintf(" Node: %s | Started: %s ago\n", alert.Node, formatDuration(time.Since(alert.StartTime))))
textBuilder.WriteString(fmt.Sprintf(" Message: %s\n", alert.Message))
}
@@ -373,4 +373,20 @@ func pluralize(count int) string {
return ""
}
return "s"
}
// formatMetricValue formats a metric value with the appropriate unit
func formatMetricValue(metricType string, value float64) string {
if metricType == "diskRead" || metricType == "diskWrite" {
return fmt.Sprintf("%.1f MB/s", value)
}
return fmt.Sprintf("%.1f%%", value)
}
// formatMetricThreshold formats a metric threshold with the appropriate unit
func formatMetricThreshold(metricType string, threshold float64) string {
if metricType == "diskRead" || metricType == "diskWrite" {
return fmt.Sprintf("%.0f MB/s", threshold)
}
return fmt.Sprintf("%.0f%%", threshold)
}
+9 -9
View File
@@ -32,8 +32,8 @@ func GetWebhookTemplates() []WebhookTemplate {
{"name": "Resource", "value": "{{.ResourceName}}", "inline": true},
{"name": "Node", "value": "{{.Node}}", "inline": true},
{"name": "Type", "value": "{{.Type | title}}", "inline": true},
{"name": "Value", "value": "{{printf "%.1f" .Value}}%", "inline": true},
{"name": "Threshold", "value": "{{printf "%.0f" .Threshold}}%", "inline": true},
{"name": "Value", "value": "{{if or (eq .Type \"diskRead\") (eq .Type \"diskWrite\")}}{{printf \"%.1f\" .Value}} MB/s{{else}}{{printf \"%.1f\" .Value}}%{{end}}", "inline": true},
{"name": "Threshold", "value": "{{if or (eq .Type \"diskRead\") (eq .Type \"diskWrite\")}}{{printf \"%.0f\" .Threshold}} MB/s{{else}}{{printf \"%.0f\" .Threshold}}%{{end}}", "inline": true},
{"name": "Duration", "value": "{{.Duration}}", "inline": true}
],
"timestamp": "{{.Timestamp}}",
@@ -53,7 +53,7 @@ func GetWebhookTemplates() []WebhookTemplate {
Headers: map[string]string{"Content-Type": "application/json"},
PayloadTemplate: `{
"chat_id": "{{.ChatID}}",
"text": "🚨 *Pulse Alert: {{.Level | title}}*\n\n{{.Message}}\n\n📊 *Details:*\n• Resource: {{.ResourceName}}\n• Node: {{.Node}}\n• Type: {{.Type | title}}\n• Value: {{printf "%.1f" .Value}}%\n• Threshold: {{printf "%.0f" .Threshold}}%\n• Duration: {{.Duration}}\n\n🔗 [View in Pulse]({{.Instance}})",
"text": "🚨 *Pulse Alert: {{.Level | title}}*\n\n{{.Message}}\n\n📊 *Details:*\n• Resource: {{.ResourceName}}\n• Node: {{.Node}}\n• Type: {{.Type | title}}\n• Value: {{if or (eq .Type \"diskRead\") (eq .Type \"diskWrite\")}}{{printf \"%.1f\" .Value}} MB/s{{else}}{{printf \"%.1f\" .Value}}%{{end}}\n• Threshold: {{if or (eq .Type \"diskRead\") (eq .Type \"diskWrite\")}}{{printf \"%.0f\" .Threshold}} MB/s{{else}}{{printf \"%.0f\" .Threshold}}%{{end}}\n• Duration: {{.Duration}}\n\n🔗 [View in Pulse]({{.Instance}})",
"parse_mode": "Markdown",
"disable_web_page_preview": true
}`,
@@ -89,8 +89,8 @@ func GetWebhookTemplates() []WebhookTemplate {
{"type": "mrkdwn", "text": "*Resource:*\n{{.ResourceName}}"},
{"type": "mrkdwn", "text": "*Node:*\n{{.Node}}"},
{"type": "mrkdwn", "text": "*Type:*\n{{.Type | title}}"},
{"type": "mrkdwn", "text": "*Value:*\n{{printf "%.1f" .Value}}%"},
{"type": "mrkdwn", "text": "*Threshold:*\n{{printf "%.0f" .Threshold}}%"},
{"type": "mrkdwn", "text": "*Value:*\n{{if or (eq .Type \"diskRead\") (eq .Type \"diskWrite\")}}{{printf \"%.1f\" .Value}} MB/s{{else}}{{printf \"%.1f\" .Value}}%{{end}}"},
{"type": "mrkdwn", "text": "*Threshold:*\n{{if or (eq .Type \"diskRead\") (eq .Type \"diskWrite\")}}{{printf \"%.0f\" .Threshold}} MB/s{{else}}{{printf \"%.0f\" .Threshold}}%{{end}}"},
{"type": "mrkdwn", "text": "*Duration:*\n{{.Duration}}"}
]
},
@@ -125,8 +125,8 @@ func GetWebhookTemplates() []WebhookTemplate {
{"name": "Resource", "value": "{{.ResourceName}}"},
{"name": "Node", "value": "{{.Node}}"},
{"name": "Type", "value": "{{.Type | title}}"},
{"name": "Value", "value": "{{printf "%.1f" .Value}}%"},
{"name": "Threshold", "value": "{{printf "%.0f" .Threshold}}%"},
{"name": "Value", "value": "{{if or (eq .Type \"diskRead\") (eq .Type \"diskWrite\")}}{{printf \"%.1f\" .Value}} MB/s{{else}}{{printf \"%.1f\" .Value}}%{{end}}"},
{"name": "Threshold", "value": "{{if or (eq .Type \"diskRead\") (eq .Type \"diskWrite\")}}{{printf \"%.0f\" .Threshold}} MB/s{{else}}{{printf \"%.0f\" .Threshold}}%{{end}}"},
{"name": "Duration", "value": "{{.Duration}}"},
{"name": "Instance", "value": "{{.Instance}}"}
],
@@ -167,8 +167,8 @@ func GetWebhookTemplates() []WebhookTemplate {
"custom_details": {
"alert_id": "{{.ID}}",
"resource_type": "{{.Type}}",
"current_value": "{{printf "%.1f" .Value}}%",
"threshold": "{{printf "%.0f" .Threshold}}%",
"current_value": "{{if or (eq .Type \"diskRead\") (eq .Type \"diskWrite\")}}{{printf \"%.1f\" .Value}} MB/s{{else}}{{printf \"%.1f\" .Value}}%{{end}}",
"threshold": "{{if or (eq .Type \"diskRead\") (eq .Type \"diskWrite\")}}{{printf \"%.0f\" .Threshold}} MB/s{{else}}{{printf \"%.0f\" .Threshold}}%{{end}}",
"duration": "{{.Duration}}",
"instance": "{{.Instance}}"
}