mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-09-22 19:23:31 +00:00
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:
File diff suppressed because it is too large
Load Diff
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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}}"
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user