mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-09-22 03:04:03 +00:00
378ebcb250
Security enhancements: - Fixed critical issue: PBS tokens no longer logged in plaintext - PVE tokens now properly masked in all log outputs - Enhanced token security documentation Alert system fixes: - Fixed storage alerts not working due to threshold being 0 - Added automatic defaults preservation for alert thresholds - Storage alerts now properly trigger at 85% usage Node management improvements: - Fixed node deletion causing 'Node not found' errors - Added instant discovery refresh when nodes are deleted - Added manual refresh buttons for discovery - Fixed PBS token cleanup in auto-registration scripts - Fixed /dev/tty errors when running scripts in Docker containers Bug fixes: - Fixed CPU MHz field type mismatch causing JSON unmarshal errors - Suppressed non-critical container snapshot API errors - Fixed auto-registration using Docker internal IPs instead of actual host IPs Documentation updates: - Added comprehensive security documentation - Streamlined setup documentation focusing on ease of use - Removed marketing language and consolidated repetitive content Frontend improvements: - Added WebSocket support for real-time node updates - Added discovery refresh buttons in Settings - Improved node deletion feedback
1393 lines
43 KiB
Go
1393 lines
43 KiB
Go
package alerts
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/rcourtman/pulse-go-rewrite/internal/models"
|
|
"github.com/rcourtman/pulse-go-rewrite/internal/utils"
|
|
"github.com/rs/zerolog/log"
|
|
)
|
|
|
|
// AlertLevel represents the severity of an alert
|
|
type AlertLevel string
|
|
|
|
const (
|
|
AlertLevelWarning AlertLevel = "warning"
|
|
AlertLevelCritical AlertLevel = "critical"
|
|
)
|
|
|
|
// Alert represents an active alert
|
|
type Alert struct {
|
|
ID string `json:"id"`
|
|
Type string `json:"type"` // cpu, memory, disk, etc.
|
|
Level AlertLevel `json:"level"`
|
|
ResourceID string `json:"resourceId"` // guest or node ID
|
|
ResourceName string `json:"resourceName"`
|
|
Node string `json:"node"`
|
|
Instance string `json:"instance"`
|
|
Message string `json:"message"`
|
|
Value float64 `json:"value"`
|
|
Threshold float64 `json:"threshold"`
|
|
StartTime time.Time `json:"startTime"`
|
|
LastSeen time.Time `json:"lastSeen"`
|
|
Acknowledged bool `json:"acknowledged"`
|
|
AckTime *time.Time `json:"ackTime,omitempty"`
|
|
AckUser string `json:"ackUser,omitempty"`
|
|
Metadata map[string]interface{} `json:"metadata,omitempty"`
|
|
// Escalation tracking
|
|
LastEscalation int `json:"lastEscalation,omitempty"` // Last escalation level notified
|
|
EscalationTimes []time.Time `json:"escalationTimes,omitempty"` // Times when escalations were sent
|
|
}
|
|
|
|
// ResolvedAlert represents a recently resolved alert
|
|
type ResolvedAlert struct {
|
|
*Alert
|
|
ResolvedTime time.Time `json:"resolvedTime"`
|
|
}
|
|
|
|
// HysteresisThreshold represents a threshold with hysteresis
|
|
type HysteresisThreshold struct {
|
|
Trigger float64 `json:"trigger"` // Threshold to trigger alert
|
|
Clear float64 `json:"clear"` // Threshold to clear alert
|
|
}
|
|
|
|
// ThresholdConfig represents threshold configuration
|
|
type ThresholdConfig struct {
|
|
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"`
|
|
DiskLegacy *float64 `json:"diskLegacy,omitempty"`
|
|
DiskReadLegacy *float64 `json:"diskReadLegacy,omitempty"`
|
|
DiskWriteLegacy *float64 `json:"diskWriteLegacy,omitempty"`
|
|
NetworkInLegacy *float64 `json:"networkInLegacy,omitempty"`
|
|
NetworkOutLegacy *float64 `json:"networkOutLegacy,omitempty"`
|
|
}
|
|
|
|
// QuietHours represents quiet hours configuration
|
|
type QuietHours struct {
|
|
Enabled bool `json:"enabled"`
|
|
Start string `json:"start"` // 24-hour format "HH:MM"
|
|
End string `json:"end"` // 24-hour format "HH:MM"
|
|
Timezone string `json:"timezone"`
|
|
Days map[string]bool `json:"days"` // monday, tuesday, etc.
|
|
}
|
|
|
|
// EscalationLevel represents an escalation rule
|
|
type EscalationLevel struct {
|
|
After int `json:"after"` // minutes after initial alert
|
|
Notify string `json:"notify"` // "email", "webhook", or "all"
|
|
}
|
|
|
|
// EscalationConfig represents alert escalation configuration
|
|
type EscalationConfig struct {
|
|
Enabled bool `json:"enabled"`
|
|
Levels []EscalationLevel `json:"levels"`
|
|
}
|
|
|
|
// GroupingConfig represents alert grouping configuration
|
|
type GroupingConfig struct {
|
|
Enabled bool `json:"enabled"`
|
|
Window int `json:"window"` // seconds
|
|
ByNode bool `json:"byNode"` // Group alerts by node
|
|
ByGuest bool `json:"byGuest"` // Group alerts by guest type
|
|
}
|
|
|
|
// ScheduleConfig represents alerting schedule configuration
|
|
type ScheduleConfig struct {
|
|
QuietHours QuietHours `json:"quietHours"`
|
|
Cooldown int `json:"cooldown"` // minutes
|
|
GroupingWindow int `json:"groupingWindow"` // seconds (deprecated, use Grouping.Window)
|
|
MaxAlertsHour int `json:"maxAlertsHour"` // max alerts per hour per resource
|
|
Escalation EscalationConfig `json:"escalation"`
|
|
Grouping GroupingConfig `json:"grouping"`
|
|
}
|
|
|
|
// FilterCondition represents a single filter condition
|
|
type FilterCondition struct {
|
|
Type string `json:"type"` // "metric", "text", or "raw"
|
|
Field string `json:"field,omitempty"`
|
|
Operator string `json:"operator,omitempty"`
|
|
Value interface{} `json:"value,omitempty"`
|
|
RawText string `json:"rawText,omitempty"`
|
|
}
|
|
|
|
// FilterStack represents a collection of filters with logical operator
|
|
type FilterStack struct {
|
|
Filters []FilterCondition `json:"filters"`
|
|
LogicalOperator string `json:"logicalOperator"` // "AND" or "OR"
|
|
}
|
|
|
|
// CustomAlertRule represents a custom alert rule with filter conditions
|
|
type CustomAlertRule struct {
|
|
ID string `json:"id"`
|
|
Name string `json:"name"`
|
|
Description string `json:"description,omitempty"`
|
|
FilterConditions FilterStack `json:"filterConditions"`
|
|
Thresholds ThresholdConfig `json:"thresholds"`
|
|
Priority int `json:"priority"`
|
|
Enabled bool `json:"enabled"`
|
|
Notifications struct {
|
|
Email *struct {
|
|
Enabled bool `json:"enabled"`
|
|
Recipients []string `json:"recipients"`
|
|
} `json:"email,omitempty"`
|
|
Webhook *struct {
|
|
Enabled bool `json:"enabled"`
|
|
URL string `json:"url"`
|
|
} `json:"webhook,omitempty"`
|
|
} `json:"notifications"`
|
|
CreatedAt time.Time `json:"createdAt"`
|
|
UpdatedAt time.Time `json:"updatedAt"`
|
|
}
|
|
|
|
// AlertConfig represents the complete alert configuration
|
|
type AlertConfig struct {
|
|
Enabled bool `json:"enabled"`
|
|
GuestDefaults ThresholdConfig `json:"guestDefaults"`
|
|
NodeDefaults ThresholdConfig `json:"nodeDefaults"`
|
|
StorageDefault HysteresisThreshold `json:"storageDefault"`
|
|
Overrides map[string]ThresholdConfig `json:"overrides"` // keyed by resource ID
|
|
CustomRules []CustomAlertRule `json:"customRules,omitempty"`
|
|
Schedule ScheduleConfig `json:"schedule"`
|
|
// New configuration options
|
|
MinimumDelta float64 `json:"minimumDelta"` // Minimum % change to trigger new alert
|
|
SuppressionWindow int `json:"suppressionWindow"` // Minutes to suppress duplicate alerts
|
|
HysteresisMargin float64 `json:"hysteresisMargin"` // Default margin for legacy thresholds
|
|
TimeThreshold int `json:"timeThreshold"` // Seconds that threshold must be exceeded before triggering
|
|
}
|
|
|
|
// Manager handles alert monitoring and state
|
|
type Manager struct {
|
|
mu sync.RWMutex
|
|
config AlertConfig
|
|
activeAlerts map[string]*Alert
|
|
historyManager *HistoryManager
|
|
onAlert func(alert *Alert)
|
|
onResolved func(alertID string)
|
|
onEscalate func(alert *Alert, level int)
|
|
escalationStop chan struct{}
|
|
alertRateLimit map[string][]time.Time // Track alert times for rate limiting
|
|
// New fields for deduplication and suppression
|
|
recentAlerts map[string]*Alert // Track recent alerts for deduplication
|
|
suppressedUntil map[string]time.Time // Track suppression windows
|
|
// Recently resolved alerts (kept for 5 minutes)
|
|
recentlyResolved map[string]*ResolvedAlert
|
|
resolvedMutex sync.RWMutex
|
|
// Time threshold tracking
|
|
pendingAlerts map[string]time.Time // Track when thresholds were first exceeded
|
|
}
|
|
|
|
// NewManager creates a new alert manager
|
|
func NewManager() *Manager {
|
|
alertsDir := filepath.Join(utils.GetDataDir(), "alerts")
|
|
m := &Manager{
|
|
activeAlerts: make(map[string]*Alert),
|
|
historyManager: NewHistoryManager(alertsDir),
|
|
escalationStop: make(chan struct{}),
|
|
alertRateLimit: make(map[string][]time.Time),
|
|
recentAlerts: make(map[string]*Alert),
|
|
suppressedUntil: make(map[string]time.Time),
|
|
recentlyResolved: make(map[string]*ResolvedAlert),
|
|
pendingAlerts: make(map[string]time.Time),
|
|
config: AlertConfig{
|
|
Enabled: true,
|
|
GuestDefaults: ThresholdConfig{
|
|
CPU: &HysteresisThreshold{Trigger: 80, Clear: 75},
|
|
Memory: &HysteresisThreshold{Trigger: 85, Clear: 80},
|
|
Disk: &HysteresisThreshold{Trigger: 90, Clear: 85},
|
|
DiskRead: &HysteresisThreshold{Trigger: 150, Clear: 125}, // 150 MB/s
|
|
DiskWrite: &HysteresisThreshold{Trigger: 150, Clear: 125}, // 150 MB/s
|
|
NetworkIn: &HysteresisThreshold{Trigger: 200, Clear: 175}, // 200 MB/s
|
|
NetworkOut: &HysteresisThreshold{Trigger: 200, Clear: 175}, // 200 MB/s
|
|
},
|
|
NodeDefaults: ThresholdConfig{
|
|
CPU: &HysteresisThreshold{Trigger: 80, Clear: 75},
|
|
Memory: &HysteresisThreshold{Trigger: 85, Clear: 80},
|
|
Disk: &HysteresisThreshold{Trigger: 90, Clear: 85},
|
|
},
|
|
StorageDefault: HysteresisThreshold{Trigger: 85, Clear: 80},
|
|
MinimumDelta: 2.0, // 2% minimum change
|
|
SuppressionWindow: 5, // 5 minutes
|
|
HysteresisMargin: 5.0, // 5% default margin
|
|
Overrides: make(map[string]ThresholdConfig),
|
|
Schedule: ScheduleConfig{
|
|
QuietHours: QuietHours{
|
|
Enabled: false,
|
|
Start: "22:00",
|
|
End: "08:00",
|
|
Timezone: "America/New_York",
|
|
Days: map[string]bool{
|
|
"monday": true,
|
|
"tuesday": true,
|
|
"wednesday": true,
|
|
"thursday": true,
|
|
"friday": true,
|
|
"saturday": false,
|
|
"sunday": false,
|
|
},
|
|
},
|
|
Cooldown: 5, // 5 minutes default
|
|
GroupingWindow: 30, // 30 seconds default
|
|
MaxAlertsHour: 10, // 10 alerts per hour default
|
|
Escalation: EscalationConfig{
|
|
Enabled: false,
|
|
Levels: []EscalationLevel{
|
|
{After: 15, Notify: "email"},
|
|
{After: 30, Notify: "webhook"},
|
|
{After: 60, Notify: "all"},
|
|
},
|
|
},
|
|
Grouping: GroupingConfig{
|
|
Enabled: true,
|
|
Window: 30,
|
|
ByNode: true,
|
|
ByGuest: false,
|
|
},
|
|
},
|
|
},
|
|
}
|
|
|
|
// Load saved active alerts
|
|
if err := m.LoadActiveAlerts(); err != nil {
|
|
log.Error().Err(err).Msg("Failed to load active alerts")
|
|
}
|
|
|
|
// Start escalation checker
|
|
go m.escalationChecker()
|
|
|
|
// Start periodic save of active alerts
|
|
go m.periodicSaveAlerts()
|
|
|
|
return m
|
|
}
|
|
|
|
// SetAlertCallback sets the callback for new alerts
|
|
func (m *Manager) SetAlertCallback(cb func(alert *Alert)) {
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
m.onAlert = cb
|
|
}
|
|
|
|
// SetResolvedCallback sets the callback for resolved alerts
|
|
func (m *Manager) SetResolvedCallback(cb func(alertID string)) {
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
m.onResolved = cb
|
|
}
|
|
|
|
// SetEscalateCallback sets the callback for escalated alerts
|
|
func (m *Manager) SetEscalateCallback(cb func(alert *Alert, level int)) {
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
m.onEscalate = cb
|
|
}
|
|
|
|
// UpdateConfig updates the alert configuration
|
|
func (m *Manager) UpdateConfig(config AlertConfig) {
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
|
|
// Preserve defaults for zero values
|
|
if config.StorageDefault.Trigger <= 0 {
|
|
config.StorageDefault.Trigger = 85
|
|
config.StorageDefault.Clear = 80
|
|
}
|
|
|
|
// Ensure minimums for other important fields
|
|
if config.MinimumDelta <= 0 {
|
|
config.MinimumDelta = 2.0
|
|
}
|
|
if config.SuppressionWindow <= 0 {
|
|
config.SuppressionWindow = 5
|
|
}
|
|
if config.HysteresisMargin <= 0 {
|
|
config.HysteresisMargin = 5.0
|
|
}
|
|
|
|
m.config = config
|
|
log.Info().Msg("Alert configuration updated")
|
|
}
|
|
|
|
// isInQuietHours checks if the current time is within quiet hours
|
|
func (m *Manager) isInQuietHours() bool {
|
|
if !m.config.Schedule.QuietHours.Enabled {
|
|
return false
|
|
}
|
|
|
|
// Load timezone
|
|
loc, err := time.LoadLocation(m.config.Schedule.QuietHours.Timezone)
|
|
if err != nil {
|
|
log.Warn().Err(err).Str("timezone", m.config.Schedule.QuietHours.Timezone).Msg("Failed to load timezone, using local time")
|
|
loc = time.Local
|
|
}
|
|
|
|
now := time.Now().In(loc)
|
|
dayName := strings.ToLower(now.Format("Monday"))
|
|
|
|
// Check if today is enabled for quiet hours
|
|
if enabled, ok := m.config.Schedule.QuietHours.Days[dayName]; !ok || !enabled {
|
|
return false
|
|
}
|
|
|
|
// Parse start and end times
|
|
startTime, err := time.ParseInLocation("15:04", m.config.Schedule.QuietHours.Start, loc)
|
|
if err != nil {
|
|
log.Warn().Err(err).Str("start", m.config.Schedule.QuietHours.Start).Msg("Failed to parse quiet hours start time")
|
|
return false
|
|
}
|
|
|
|
endTime, err := time.ParseInLocation("15:04", m.config.Schedule.QuietHours.End, loc)
|
|
if err != nil {
|
|
log.Warn().Err(err).Str("end", m.config.Schedule.QuietHours.End).Msg("Failed to parse quiet hours end time")
|
|
return false
|
|
}
|
|
|
|
// Set to today's date
|
|
startTime = time.Date(now.Year(), now.Month(), now.Day(), startTime.Hour(), startTime.Minute(), 0, 0, loc)
|
|
endTime = time.Date(now.Year(), now.Month(), now.Day(), endTime.Hour(), endTime.Minute(), 0, 0, loc)
|
|
|
|
// Handle overnight quiet hours (e.g., 22:00 to 08:00)
|
|
if endTime.Before(startTime) {
|
|
// If we're past the start time or before the end time
|
|
if now.After(startTime) || now.Before(endTime) {
|
|
return true
|
|
}
|
|
} else {
|
|
// Normal case (e.g., 08:00 to 17:00)
|
|
if now.After(startTime) && now.Before(endTime) {
|
|
return true
|
|
}
|
|
}
|
|
|
|
return false
|
|
}
|
|
|
|
// GetConfig returns the current alert configuration
|
|
func (m *Manager) GetConfig() AlertConfig {
|
|
m.mu.RLock()
|
|
defer m.mu.RUnlock()
|
|
return m.config
|
|
}
|
|
|
|
// CheckGuest checks a guest (VM or container) against thresholds
|
|
func (m *Manager) CheckGuest(guest interface{}, instanceName string) {
|
|
m.mu.RLock()
|
|
if !m.config.Enabled {
|
|
m.mu.RUnlock()
|
|
return
|
|
}
|
|
m.mu.RUnlock()
|
|
|
|
var guestID, name, node, guestType, status string
|
|
var cpu, memUsage, diskUsage float64
|
|
var diskRead, diskWrite, netIn, netOut int64
|
|
|
|
// Extract data based on guest type
|
|
switch g := guest.(type) {
|
|
case models.VM:
|
|
guestID = g.ID
|
|
name = g.Name
|
|
node = g.Node
|
|
status = g.Status
|
|
guestType = "VM"
|
|
cpu = g.CPU * 100 // Convert to percentage
|
|
memUsage = g.Memory.Usage
|
|
diskUsage = g.Disk.Usage
|
|
diskRead = g.DiskRead
|
|
diskWrite = g.DiskWrite
|
|
netIn = g.NetworkIn
|
|
netOut = g.NetworkOut
|
|
case models.Container:
|
|
guestID = g.ID
|
|
name = g.Name
|
|
node = g.Node
|
|
status = g.Status
|
|
guestType = "Container"
|
|
cpu = g.CPU * 100 // Convert to percentage
|
|
memUsage = g.Memory.Usage
|
|
diskUsage = g.Disk.Usage
|
|
diskRead = g.DiskRead
|
|
diskWrite = g.DiskWrite
|
|
netIn = g.NetworkIn
|
|
netOut = g.NetworkOut
|
|
default:
|
|
return
|
|
}
|
|
|
|
// Clear any alerts for stopped guests and skip threshold checks
|
|
if status == "stopped" {
|
|
// Clear all alerts for this guest if it's stopped
|
|
m.mu.Lock()
|
|
for alertID, alert := range m.activeAlerts {
|
|
if alert.ResourceID == guestID {
|
|
delete(m.activeAlerts, alertID)
|
|
log.Info().
|
|
Str("alertID", alertID).
|
|
Str("guest", name).
|
|
Msg("Cleared alert for stopped guest")
|
|
}
|
|
}
|
|
m.mu.Unlock()
|
|
return
|
|
}
|
|
|
|
// Get thresholds (check custom rules, then overrides, then defaults)
|
|
m.mu.RLock()
|
|
thresholds := m.getGuestThresholds(guest, guestID)
|
|
m.mu.RUnlock()
|
|
|
|
// Check each metric
|
|
log.Debug().
|
|
Str("guest", name).
|
|
Float64("cpu", cpu).
|
|
Float64("memory", memUsage).
|
|
Float64("disk", diskUsage).
|
|
Interface("thresholds", thresholds).
|
|
Msg("Checking guest thresholds")
|
|
|
|
m.checkMetric(guestID, name, node, instanceName, guestType, "cpu", cpu, thresholds.CPU)
|
|
m.checkMetric(guestID, name, node, instanceName, guestType, "memory", memUsage, thresholds.Memory)
|
|
m.checkMetric(guestID, name, node, instanceName, guestType, "disk", diskUsage, thresholds.Disk)
|
|
|
|
// Check I/O metrics (convert bytes/s to MB/s)
|
|
if thresholds.DiskRead != nil && thresholds.DiskRead.Trigger > 0 {
|
|
m.checkMetric(guestID, name, node, instanceName, guestType, "diskRead", float64(diskRead)/1024/1024, thresholds.DiskRead)
|
|
}
|
|
if thresholds.DiskWrite != nil && thresholds.DiskWrite.Trigger > 0 {
|
|
m.checkMetric(guestID, name, node, instanceName, guestType, "diskWrite", float64(diskWrite)/1024/1024, thresholds.DiskWrite)
|
|
}
|
|
if thresholds.NetworkIn != nil && thresholds.NetworkIn.Trigger > 0 {
|
|
m.checkMetric(guestID, name, node, instanceName, guestType, "networkIn", float64(netIn)/1024/1024, thresholds.NetworkIn)
|
|
}
|
|
if thresholds.NetworkOut != nil && thresholds.NetworkOut.Trigger > 0 {
|
|
m.checkMetric(guestID, name, node, instanceName, guestType, "networkOut", float64(netOut)/1024/1024, thresholds.NetworkOut)
|
|
}
|
|
}
|
|
|
|
// CheckNode checks a node against thresholds
|
|
func (m *Manager) CheckNode(node models.Node) {
|
|
m.mu.RLock()
|
|
if !m.config.Enabled {
|
|
m.mu.RUnlock()
|
|
return
|
|
}
|
|
thresholds := m.config.NodeDefaults
|
|
m.mu.RUnlock()
|
|
|
|
// Check each metric
|
|
m.checkMetric(node.ID, node.Name, node.Name, node.Instance, "Node", "cpu", node.CPU*100, thresholds.CPU)
|
|
m.checkMetric(node.ID, node.Name, node.Name, node.Instance, "Node", "memory", node.Memory.Usage, thresholds.Memory)
|
|
m.checkMetric(node.ID, node.Name, node.Name, node.Instance, "Node", "disk", node.Disk.Usage, thresholds.Disk)
|
|
}
|
|
|
|
// CheckStorage checks storage against thresholds
|
|
func (m *Manager) CheckStorage(storage models.Storage) {
|
|
m.mu.RLock()
|
|
if !m.config.Enabled {
|
|
m.mu.RUnlock()
|
|
return
|
|
}
|
|
threshold := m.config.StorageDefault
|
|
m.mu.RUnlock()
|
|
|
|
m.checkMetric(storage.ID, storage.Name, storage.Node, storage.Instance, "Storage", "usage", storage.Usage, &threshold)
|
|
}
|
|
|
|
// checkMetric checks a single metric against its threshold with hysteresis
|
|
func (m *Manager) checkMetric(resourceID, resourceName, node, instance, resourceType, metricType string, value float64, threshold *HysteresisThreshold) {
|
|
if threshold == nil || threshold.Trigger <= 0 {
|
|
return
|
|
}
|
|
|
|
log.Debug().
|
|
Str("resource", resourceName).
|
|
Str("metric", metricType).
|
|
Float64("value", value).
|
|
Float64("trigger", threshold.Trigger).
|
|
Float64("clear", threshold.Clear).
|
|
Bool("exceeds", value >= threshold.Trigger).
|
|
Msg("Checking metric threshold")
|
|
|
|
alertID := fmt.Sprintf("%s-%s", resourceID, metricType)
|
|
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
|
|
existingAlert, exists := m.activeAlerts[alertID]
|
|
|
|
// Check for suppression
|
|
if suppressUntil, suppressed := m.suppressedUntil[alertID]; suppressed && time.Now().Before(suppressUntil) {
|
|
log.Debug().
|
|
Str("alertID", alertID).
|
|
Time("suppressedUntil", suppressUntil).
|
|
Msg("Alert suppressed")
|
|
return
|
|
}
|
|
|
|
if value >= threshold.Trigger {
|
|
// Threshold exceeded
|
|
if !exists {
|
|
// Check if we have a time threshold configured
|
|
if m.config.TimeThreshold > 0 {
|
|
// Check if this threshold was already pending
|
|
if pendingTime, isPending := m.pendingAlerts[alertID]; isPending {
|
|
// Check if enough time has passed
|
|
if time.Since(pendingTime) >= time.Duration(m.config.TimeThreshold)*time.Second {
|
|
// Time threshold met, proceed with alert
|
|
delete(m.pendingAlerts, alertID)
|
|
log.Debug().
|
|
Str("alertID", alertID).
|
|
Int("timeThreshold", m.config.TimeThreshold).
|
|
Dur("elapsed", time.Since(pendingTime)).
|
|
Msg("Time threshold met, triggering alert")
|
|
} else {
|
|
// Still waiting for time threshold
|
|
log.Debug().
|
|
Str("alertID", alertID).
|
|
Int("timeThreshold", m.config.TimeThreshold).
|
|
Dur("elapsed", time.Since(pendingTime)).
|
|
Msg("Threshold exceeded but waiting for time threshold")
|
|
return
|
|
}
|
|
} else {
|
|
// First time exceeding threshold, start tracking
|
|
m.pendingAlerts[alertID] = time.Now()
|
|
log.Debug().
|
|
Str("alertID", alertID).
|
|
Int("timeThreshold", m.config.TimeThreshold).
|
|
Msg("Threshold exceeded, starting time threshold tracking")
|
|
return
|
|
}
|
|
}
|
|
|
|
// Check for recent similar alert to prevent spam
|
|
if recent, hasRecent := m.recentAlerts[alertID]; hasRecent {
|
|
// Check minimum delta
|
|
if m.config.MinimumDelta > 0 &&
|
|
time.Since(recent.StartTime) < time.Duration(m.config.SuppressionWindow)*time.Minute &&
|
|
abs(recent.Value - value) < m.config.MinimumDelta {
|
|
log.Debug().
|
|
Str("alertID", alertID).
|
|
Float64("recentValue", recent.Value).
|
|
Float64("currentValue", value).
|
|
Float64("delta", abs(recent.Value - value)).
|
|
Float64("minimumDelta", m.config.MinimumDelta).
|
|
Msg("Alert suppressed due to minimum delta")
|
|
|
|
// Set suppression window
|
|
m.suppressedUntil[alertID] = time.Now().Add(time.Duration(m.config.SuppressionWindow) * time.Minute)
|
|
return
|
|
}
|
|
}
|
|
|
|
// New alert
|
|
alert := &Alert{
|
|
ID: alertID,
|
|
Type: metricType,
|
|
Level: AlertLevelWarning,
|
|
ResourceID: resourceID,
|
|
ResourceName: resourceName,
|
|
Node: node,
|
|
Instance: instance,
|
|
Message: fmt.Sprintf("%s %s usage at %.1f%% (trigger: %.0f%%, clear: %.0f%%)", resourceType, metricType, value, threshold.Trigger, threshold.Clear),
|
|
Value: value,
|
|
Threshold: threshold.Trigger,
|
|
StartTime: time.Now(),
|
|
LastSeen: time.Now(),
|
|
Metadata: map[string]interface{}{
|
|
"resourceType": resourceType,
|
|
"clearThreshold": threshold.Clear,
|
|
},
|
|
}
|
|
|
|
// Set level based on how much over threshold
|
|
if value >= threshold.Trigger + 10 {
|
|
alert.Level = AlertLevelCritical
|
|
}
|
|
|
|
m.activeAlerts[alertID] = alert
|
|
m.recentAlerts[alertID] = alert
|
|
m.historyManager.AddAlert(*alert)
|
|
|
|
// Save active alerts after adding new one
|
|
go func() {
|
|
if err := m.SaveActiveAlerts(); err != nil {
|
|
log.Error().Err(err).Msg("Failed to save active alerts after creation")
|
|
}
|
|
}()
|
|
|
|
log.Warn().
|
|
Str("alertID", alertID).
|
|
Str("resource", resourceName).
|
|
Str("metric", metricType).
|
|
Float64("value", value).
|
|
Float64("trigger", threshold.Trigger).
|
|
Float64("clear", threshold.Clear).
|
|
Int("activeAlerts", len(m.activeAlerts)).
|
|
Msg("Alert triggered")
|
|
|
|
// Check rate limit (but don't remove alert from tracking)
|
|
if !m.checkRateLimit(alertID) {
|
|
log.Debug().
|
|
Str("alertID", alertID).
|
|
Int("maxPerHour", m.config.Schedule.MaxAlertsHour).
|
|
Msg("Alert notification suppressed due to rate limit")
|
|
// Don't delete the alert, just suppress notifications
|
|
return
|
|
}
|
|
|
|
// Check if we should suppress notifications due to quiet hours
|
|
if m.isInQuietHours() && alert.Level != AlertLevelCritical {
|
|
log.Debug().
|
|
Str("alertID", alertID).
|
|
Msg("Alert notification suppressed due to quiet hours (non-critical)")
|
|
} else {
|
|
// Notify callback
|
|
if m.onAlert != nil {
|
|
log.Info().Str("alertID", alertID).Msg("Calling onAlert callback")
|
|
go m.onAlert(alert)
|
|
} else {
|
|
log.Warn().Msg("No onAlert callback set!")
|
|
}
|
|
}
|
|
} else {
|
|
// Update existing alert
|
|
existingAlert.LastSeen = time.Now()
|
|
existingAlert.Value = value
|
|
|
|
// Update level if needed
|
|
if value >= threshold.Trigger + 10 {
|
|
existingAlert.Level = AlertLevelCritical
|
|
} else {
|
|
existingAlert.Level = AlertLevelWarning
|
|
}
|
|
}
|
|
} else {
|
|
// Value is below trigger threshold
|
|
// Clear any pending alert for this metric
|
|
if _, isPending := m.pendingAlerts[alertID]; isPending {
|
|
delete(m.pendingAlerts, alertID)
|
|
log.Debug().
|
|
Str("alertID", alertID).
|
|
Msg("Value dropped below threshold, clearing pending alert")
|
|
}
|
|
|
|
if exists {
|
|
// Use hysteresis for resolution - only resolve if below clear threshold
|
|
clearThreshold := threshold.Clear
|
|
if clearThreshold <= 0 {
|
|
clearThreshold = threshold.Trigger // Fallback to trigger if clear not set
|
|
}
|
|
|
|
if value <= clearThreshold {
|
|
// Threshold cleared with hysteresis - auto resolve
|
|
resolvedAlert := &ResolvedAlert{
|
|
Alert: existingAlert,
|
|
ResolvedTime: time.Now(),
|
|
}
|
|
|
|
// Remove from active alerts
|
|
delete(m.activeAlerts, alertID)
|
|
|
|
// Save active alerts after resolution
|
|
go func() {
|
|
if err := m.SaveActiveAlerts(); err != nil {
|
|
log.Error().Err(err).Msg("Failed to save active alerts after resolution")
|
|
}
|
|
}()
|
|
|
|
// Add to recently resolved
|
|
m.resolvedMutex.Lock()
|
|
m.recentlyResolved[alertID] = resolvedAlert
|
|
m.resolvedMutex.Unlock()
|
|
|
|
log.Info().
|
|
Str("alertID", alertID).
|
|
Int("totalRecentlyResolved", len(m.recentlyResolved)).
|
|
Msg("Added alert to recently resolved")
|
|
|
|
// Schedule cleanup after 5 minutes
|
|
go func() {
|
|
time.Sleep(5 * time.Minute)
|
|
m.resolvedMutex.Lock()
|
|
delete(m.recentlyResolved, alertID)
|
|
m.resolvedMutex.Unlock()
|
|
}()
|
|
|
|
log.Info().
|
|
Str("resource", resourceName).
|
|
Str("metric", metricType).
|
|
Float64("value", value).
|
|
Float64("clearThreshold", clearThreshold).
|
|
Bool("wasAcknowledged", existingAlert.Acknowledged).
|
|
Msg("Alert resolved with hysteresis")
|
|
|
|
if m.onResolved != nil {
|
|
go m.onResolved(alertID)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// abs returns the absolute value of a float64
|
|
func abs(x float64) float64 {
|
|
if x < 0 {
|
|
return -x
|
|
}
|
|
return x
|
|
}
|
|
|
|
// AcknowledgeAlert acknowledges an alert
|
|
func (m *Manager) AcknowledgeAlert(alertID, user string) error {
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
|
|
alert, exists := m.activeAlerts[alertID]
|
|
if !exists {
|
|
return fmt.Errorf("alert not found: %s", alertID)
|
|
}
|
|
|
|
alert.Acknowledged = true
|
|
now := time.Now()
|
|
alert.AckTime = &now
|
|
alert.AckUser = user
|
|
|
|
return nil
|
|
}
|
|
|
|
// GetActiveAlerts returns all active alerts
|
|
func (m *Manager) GetActiveAlerts() []Alert {
|
|
m.mu.RLock()
|
|
defer m.mu.RUnlock()
|
|
|
|
alerts := make([]Alert, 0, len(m.activeAlerts))
|
|
for _, alert := range m.activeAlerts {
|
|
alerts = append(alerts, *alert)
|
|
}
|
|
return alerts
|
|
}
|
|
|
|
// GetRecentlyResolved returns recently resolved alerts
|
|
func (m *Manager) GetRecentlyResolved() []models.ResolvedAlert {
|
|
m.resolvedMutex.RLock()
|
|
defer m.resolvedMutex.RUnlock()
|
|
|
|
resolved := make([]models.ResolvedAlert, 0, len(m.recentlyResolved))
|
|
for _, alert := range m.recentlyResolved {
|
|
resolved = append(resolved, models.ResolvedAlert{
|
|
Alert: models.Alert{
|
|
ID: alert.ID,
|
|
Type: alert.Type,
|
|
Level: string(alert.Level),
|
|
ResourceID: alert.ResourceID,
|
|
ResourceName: alert.ResourceName,
|
|
Node: alert.Node,
|
|
Instance: alert.Instance,
|
|
Message: alert.Message,
|
|
Value: alert.Value,
|
|
Threshold: alert.Threshold,
|
|
StartTime: alert.StartTime,
|
|
Acknowledged: alert.Acknowledged,
|
|
},
|
|
ResolvedTime: alert.ResolvedTime,
|
|
})
|
|
}
|
|
return resolved
|
|
}
|
|
|
|
// GetAlertHistory returns alert history
|
|
func (m *Manager) GetAlertHistory(limit int) []Alert {
|
|
return m.historyManager.GetAllHistory(limit)
|
|
}
|
|
|
|
// ClearAlertHistory clears all alert history
|
|
func (m *Manager) ClearAlertHistory() error {
|
|
return m.historyManager.ClearAllHistory()
|
|
}
|
|
|
|
|
|
// ClearAlert manually clears an alert
|
|
func (m *Manager) ClearAlert(alertID string) {
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
|
|
delete(m.activeAlerts, alertID)
|
|
|
|
if m.onResolved != nil {
|
|
go m.onResolved(alertID)
|
|
}
|
|
}
|
|
|
|
// Cleanup removes old acknowledged alerts and cleans up tracking maps
|
|
func (m *Manager) Cleanup(maxAge time.Duration) {
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
|
|
now := time.Now()
|
|
|
|
// Clean up acknowledged alerts
|
|
for id, alert := range m.activeAlerts {
|
|
if alert.Acknowledged && alert.AckTime != nil && now.Sub(*alert.AckTime) > maxAge {
|
|
delete(m.activeAlerts, id)
|
|
}
|
|
}
|
|
|
|
// Clean up recent alerts older than suppression window
|
|
suppressionWindow := time.Duration(m.config.SuppressionWindow) * time.Minute
|
|
if suppressionWindow == 0 {
|
|
suppressionWindow = 5 * time.Minute // Default
|
|
}
|
|
|
|
for id, alert := range m.recentAlerts {
|
|
if now.Sub(alert.StartTime) > suppressionWindow {
|
|
delete(m.recentAlerts, id)
|
|
}
|
|
}
|
|
|
|
// Clean up expired suppressions
|
|
for id, suppressUntil := range m.suppressedUntil {
|
|
if now.After(suppressUntil) {
|
|
delete(m.suppressedUntil, id)
|
|
}
|
|
}
|
|
}
|
|
|
|
|
|
// convertLegacyThreshold converts a legacy float64 threshold to HysteresisThreshold
|
|
func (m *Manager) convertLegacyThreshold(legacy *float64) *HysteresisThreshold {
|
|
if legacy == nil || *legacy <= 0 {
|
|
return nil
|
|
}
|
|
margin := m.config.HysteresisMargin
|
|
if margin <= 0 {
|
|
margin = 5.0 // Default 5% margin
|
|
}
|
|
return &HysteresisThreshold{
|
|
Trigger: *legacy,
|
|
Clear: *legacy - margin,
|
|
}
|
|
}
|
|
|
|
// ensureHysteresisThreshold ensures a threshold has hysteresis configured
|
|
func ensureHysteresisThreshold(threshold *HysteresisThreshold) *HysteresisThreshold {
|
|
if threshold == nil {
|
|
return nil
|
|
}
|
|
if threshold.Clear <= 0 {
|
|
threshold.Clear = threshold.Trigger - 5.0 // Default 5% margin
|
|
}
|
|
return threshold
|
|
}
|
|
|
|
// evaluateFilterCondition evaluates a single filter condition against a guest
|
|
func (m *Manager) evaluateFilterCondition(guest interface{}, condition FilterCondition) bool {
|
|
switch g := guest.(type) {
|
|
case models.VM:
|
|
return m.evaluateVMCondition(g, condition)
|
|
case models.Container:
|
|
return m.evaluateContainerCondition(g, condition)
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
// evaluateVMCondition evaluates a filter condition against a VM
|
|
func (m *Manager) evaluateVMCondition(vm models.VM, condition FilterCondition) bool {
|
|
switch condition.Type {
|
|
case "metric":
|
|
value := 0.0
|
|
switch strings.ToLower(condition.Field) {
|
|
case "cpu":
|
|
value = vm.CPU * 100
|
|
case "memory":
|
|
value = vm.Memory.Usage
|
|
case "disk":
|
|
value = vm.Disk.Usage
|
|
case "diskread":
|
|
value = float64(vm.DiskRead) / 1024 / 1024 // Convert to MB/s
|
|
case "diskwrite":
|
|
value = float64(vm.DiskWrite) / 1024 / 1024
|
|
case "networkin":
|
|
value = float64(vm.NetworkIn) / 1024 / 1024
|
|
case "networkout":
|
|
value = float64(vm.NetworkOut) / 1024 / 1024
|
|
default:
|
|
return false
|
|
}
|
|
|
|
condValue, ok := condition.Value.(float64)
|
|
if !ok {
|
|
// Try to convert from int
|
|
if intVal, ok := condition.Value.(int); ok {
|
|
condValue = float64(intVal)
|
|
} else {
|
|
return false
|
|
}
|
|
}
|
|
|
|
switch condition.Operator {
|
|
case ">":
|
|
return value > condValue
|
|
case "<":
|
|
return value < condValue
|
|
case ">=":
|
|
return value >= condValue
|
|
case "<=":
|
|
return value <= condValue
|
|
case "=", "==":
|
|
return value >= condValue - 0.5 && value <= condValue + 0.5
|
|
}
|
|
|
|
case "text":
|
|
searchValue := strings.ToLower(fmt.Sprintf("%v", condition.Value))
|
|
switch strings.ToLower(condition.Field) {
|
|
case "name":
|
|
return strings.Contains(strings.ToLower(vm.Name), searchValue)
|
|
case "node":
|
|
return strings.Contains(strings.ToLower(vm.Node), searchValue)
|
|
case "vmid":
|
|
return strings.Contains(vm.ID, searchValue)
|
|
}
|
|
|
|
case "raw":
|
|
if condition.RawText != "" {
|
|
term := strings.ToLower(condition.RawText)
|
|
return strings.Contains(strings.ToLower(vm.Name), term) ||
|
|
strings.Contains(vm.ID, term) ||
|
|
strings.Contains(strings.ToLower(vm.Node), term) ||
|
|
strings.Contains(strings.ToLower(vm.Status), term)
|
|
}
|
|
}
|
|
|
|
return false
|
|
}
|
|
|
|
// evaluateContainerCondition evaluates a filter condition against a Container
|
|
func (m *Manager) evaluateContainerCondition(ct models.Container, condition FilterCondition) bool {
|
|
// Similar logic to evaluateVMCondition but for Container type
|
|
switch condition.Type {
|
|
case "metric":
|
|
value := 0.0
|
|
switch strings.ToLower(condition.Field) {
|
|
case "cpu":
|
|
value = ct.CPU * 100
|
|
case "memory":
|
|
value = ct.Memory.Usage
|
|
case "disk":
|
|
value = ct.Disk.Usage
|
|
case "diskread":
|
|
value = float64(ct.DiskRead) / 1024 / 1024
|
|
case "diskwrite":
|
|
value = float64(ct.DiskWrite) / 1024 / 1024
|
|
case "networkin":
|
|
value = float64(ct.NetworkIn) / 1024 / 1024
|
|
case "networkout":
|
|
value = float64(ct.NetworkOut) / 1024 / 1024
|
|
default:
|
|
return false
|
|
}
|
|
|
|
condValue, ok := condition.Value.(float64)
|
|
if !ok {
|
|
if intVal, ok := condition.Value.(int); ok {
|
|
condValue = float64(intVal)
|
|
} else {
|
|
return false
|
|
}
|
|
}
|
|
|
|
switch condition.Operator {
|
|
case ">":
|
|
return value > condValue
|
|
case "<":
|
|
return value < condValue
|
|
case ">=":
|
|
return value >= condValue
|
|
case "<=":
|
|
return value <= condValue
|
|
case "=", "==":
|
|
return value >= condValue - 0.5 && value <= condValue + 0.5
|
|
}
|
|
|
|
case "text":
|
|
searchValue := strings.ToLower(fmt.Sprintf("%v", condition.Value))
|
|
switch strings.ToLower(condition.Field) {
|
|
case "name":
|
|
return strings.Contains(strings.ToLower(ct.Name), searchValue)
|
|
case "node":
|
|
return strings.Contains(strings.ToLower(ct.Node), searchValue)
|
|
case "vmid":
|
|
return strings.Contains(ct.ID, searchValue)
|
|
}
|
|
|
|
case "raw":
|
|
if condition.RawText != "" {
|
|
term := strings.ToLower(condition.RawText)
|
|
return strings.Contains(strings.ToLower(ct.Name), term) ||
|
|
strings.Contains(ct.ID, term) ||
|
|
strings.Contains(strings.ToLower(ct.Node), term) ||
|
|
strings.Contains(strings.ToLower(ct.Status), term)
|
|
}
|
|
}
|
|
|
|
return false
|
|
}
|
|
|
|
// evaluateFilterStack evaluates a filter stack against a guest
|
|
func (m *Manager) evaluateFilterStack(guest interface{}, stack FilterStack) bool {
|
|
if len(stack.Filters) == 0 {
|
|
return true
|
|
}
|
|
|
|
results := make([]bool, len(stack.Filters))
|
|
for i, filter := range stack.Filters {
|
|
results[i] = m.evaluateFilterCondition(guest, filter)
|
|
}
|
|
|
|
// Apply logical operator
|
|
if stack.LogicalOperator == "AND" {
|
|
for _, result := range results {
|
|
if !result {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
} else { // OR
|
|
for _, result := range results {
|
|
if result {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
}
|
|
|
|
// getGuestThresholds returns the appropriate thresholds for a guest
|
|
// Priority: Guest-specific overrides > Custom rules (by priority) > Global defaults
|
|
func (m *Manager) getGuestThresholds(guest interface{}, guestID string) ThresholdConfig {
|
|
// Start with defaults
|
|
thresholds := m.config.GuestDefaults
|
|
|
|
// Check custom rules (sorted by priority, highest first)
|
|
var applicableRule *CustomAlertRule
|
|
highestPriority := -1
|
|
|
|
for i := range m.config.CustomRules {
|
|
rule := &m.config.CustomRules[i]
|
|
if !rule.Enabled {
|
|
continue
|
|
}
|
|
|
|
// Check if this rule applies to the guest
|
|
if m.evaluateFilterStack(guest, rule.FilterConditions) {
|
|
if rule.Priority > highestPriority {
|
|
applicableRule = rule
|
|
highestPriority = rule.Priority
|
|
}
|
|
}
|
|
}
|
|
|
|
// Apply custom rule thresholds if found
|
|
if applicableRule != nil {
|
|
if applicableRule.Thresholds.CPU != nil {
|
|
thresholds.CPU = ensureHysteresisThreshold(applicableRule.Thresholds.CPU)
|
|
} else if applicableRule.Thresholds.CPULegacy != nil {
|
|
thresholds.CPU = m.convertLegacyThreshold(applicableRule.Thresholds.CPULegacy)
|
|
}
|
|
if applicableRule.Thresholds.Memory != nil {
|
|
thresholds.Memory = ensureHysteresisThreshold(applicableRule.Thresholds.Memory)
|
|
} else if applicableRule.Thresholds.MemoryLegacy != nil {
|
|
thresholds.Memory = m.convertLegacyThreshold(applicableRule.Thresholds.MemoryLegacy)
|
|
}
|
|
if applicableRule.Thresholds.Disk != nil {
|
|
thresholds.Disk = ensureHysteresisThreshold(applicableRule.Thresholds.Disk)
|
|
} else if applicableRule.Thresholds.DiskLegacy != nil {
|
|
thresholds.Disk = m.convertLegacyThreshold(applicableRule.Thresholds.DiskLegacy)
|
|
}
|
|
if applicableRule.Thresholds.DiskRead != nil {
|
|
thresholds.DiskRead = ensureHysteresisThreshold(applicableRule.Thresholds.DiskRead)
|
|
} else if applicableRule.Thresholds.DiskReadLegacy != nil {
|
|
thresholds.DiskRead = m.convertLegacyThreshold(applicableRule.Thresholds.DiskReadLegacy)
|
|
}
|
|
if applicableRule.Thresholds.DiskWrite != nil {
|
|
thresholds.DiskWrite = ensureHysteresisThreshold(applicableRule.Thresholds.DiskWrite)
|
|
} else if applicableRule.Thresholds.DiskWriteLegacy != nil {
|
|
thresholds.DiskWrite = m.convertLegacyThreshold(applicableRule.Thresholds.DiskWriteLegacy)
|
|
}
|
|
if applicableRule.Thresholds.NetworkIn != nil {
|
|
thresholds.NetworkIn = ensureHysteresisThreshold(applicableRule.Thresholds.NetworkIn)
|
|
} else if applicableRule.Thresholds.NetworkInLegacy != nil {
|
|
thresholds.NetworkIn = m.convertLegacyThreshold(applicableRule.Thresholds.NetworkInLegacy)
|
|
}
|
|
if applicableRule.Thresholds.NetworkOut != nil {
|
|
thresholds.NetworkOut = ensureHysteresisThreshold(applicableRule.Thresholds.NetworkOut)
|
|
} else if applicableRule.Thresholds.NetworkOutLegacy != nil {
|
|
thresholds.NetworkOut = m.convertLegacyThreshold(applicableRule.Thresholds.NetworkOutLegacy)
|
|
}
|
|
|
|
log.Debug().
|
|
Str("guest", guestID).
|
|
Str("rule", applicableRule.Name).
|
|
Int("priority", applicableRule.Priority).
|
|
Msg("Applied custom alert rule")
|
|
}
|
|
|
|
// Finally check guest-specific overrides (highest priority)
|
|
if override, exists := m.config.Overrides[guestID]; exists {
|
|
if override.CPU != nil {
|
|
thresholds.CPU = ensureHysteresisThreshold(override.CPU)
|
|
} else if override.CPULegacy != nil {
|
|
thresholds.CPU = m.convertLegacyThreshold(override.CPULegacy)
|
|
}
|
|
if override.Memory != nil {
|
|
thresholds.Memory = ensureHysteresisThreshold(override.Memory)
|
|
} else if override.MemoryLegacy != nil {
|
|
thresholds.Memory = m.convertLegacyThreshold(override.MemoryLegacy)
|
|
}
|
|
if override.Disk != nil {
|
|
thresholds.Disk = ensureHysteresisThreshold(override.Disk)
|
|
} else if override.DiskLegacy != nil {
|
|
thresholds.Disk = m.convertLegacyThreshold(override.DiskLegacy)
|
|
}
|
|
if override.DiskRead != nil {
|
|
thresholds.DiskRead = ensureHysteresisThreshold(override.DiskRead)
|
|
} else if override.DiskReadLegacy != nil {
|
|
thresholds.DiskRead = m.convertLegacyThreshold(override.DiskReadLegacy)
|
|
}
|
|
if override.DiskWrite != nil {
|
|
thresholds.DiskWrite = ensureHysteresisThreshold(override.DiskWrite)
|
|
} else if override.DiskWriteLegacy != nil {
|
|
thresholds.DiskWrite = m.convertLegacyThreshold(override.DiskWriteLegacy)
|
|
}
|
|
if override.NetworkIn != nil {
|
|
thresholds.NetworkIn = ensureHysteresisThreshold(override.NetworkIn)
|
|
} else if override.NetworkInLegacy != nil {
|
|
thresholds.NetworkIn = m.convertLegacyThreshold(override.NetworkInLegacy)
|
|
}
|
|
if override.NetworkOut != nil {
|
|
thresholds.NetworkOut = ensureHysteresisThreshold(override.NetworkOut)
|
|
} else if override.NetworkOutLegacy != nil {
|
|
thresholds.NetworkOut = m.convertLegacyThreshold(override.NetworkOutLegacy)
|
|
}
|
|
}
|
|
|
|
return thresholds
|
|
}
|
|
|
|
// checkRateLimit checks if an alert has exceeded rate limit
|
|
func (m *Manager) checkRateLimit(alertID string) bool {
|
|
if m.config.Schedule.MaxAlertsHour <= 0 {
|
|
return true // No rate limit
|
|
}
|
|
|
|
now := time.Now()
|
|
cutoff := now.Add(-1 * time.Hour)
|
|
|
|
// Clean old entries and count recent alerts
|
|
var recentAlerts []time.Time
|
|
if times, exists := m.alertRateLimit[alertID]; exists {
|
|
for _, t := range times {
|
|
if t.After(cutoff) {
|
|
recentAlerts = append(recentAlerts, t)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Check if we've hit the limit
|
|
if len(recentAlerts) >= m.config.Schedule.MaxAlertsHour {
|
|
return false
|
|
}
|
|
|
|
// Add current time
|
|
recentAlerts = append(recentAlerts, now)
|
|
m.alertRateLimit[alertID] = recentAlerts
|
|
|
|
return true
|
|
}
|
|
|
|
// escalationChecker runs periodically to check for alerts that need escalation and cleanup
|
|
func (m *Manager) escalationChecker() {
|
|
ticker := time.NewTicker(1 * time.Minute)
|
|
cleanupTicker := time.NewTicker(10 * time.Minute) // Run cleanup every 10 minutes
|
|
defer ticker.Stop()
|
|
defer cleanupTicker.Stop()
|
|
|
|
for {
|
|
select {
|
|
case <-ticker.C:
|
|
m.checkEscalations()
|
|
case <-cleanupTicker.C:
|
|
m.Cleanup(24 * time.Hour) // Clean up acknowledged alerts older than 24 hours
|
|
case <-m.escalationStop:
|
|
return
|
|
}
|
|
}
|
|
}
|
|
|
|
// checkEscalations checks all active alerts for escalation
|
|
func (m *Manager) checkEscalations() {
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
|
|
if !m.config.Schedule.Escalation.Enabled {
|
|
return
|
|
}
|
|
|
|
now := time.Now()
|
|
for _, alert := range m.activeAlerts {
|
|
// Skip acknowledged alerts
|
|
if alert.Acknowledged {
|
|
continue
|
|
}
|
|
|
|
// Check each escalation level
|
|
for i, level := range m.config.Schedule.Escalation.Levels {
|
|
// Skip if we've already escalated to this level
|
|
if alert.LastEscalation >= i+1 {
|
|
continue
|
|
}
|
|
|
|
// Check if it's time to escalate
|
|
escalateTime := alert.StartTime.Add(time.Duration(level.After) * time.Minute)
|
|
if now.After(escalateTime) {
|
|
// Update alert escalation state
|
|
alert.LastEscalation = i + 1
|
|
alert.EscalationTimes = append(alert.EscalationTimes, now)
|
|
|
|
log.Info().
|
|
Str("alertID", alert.ID).
|
|
Int("level", i+1).
|
|
Str("notify", level.Notify).
|
|
Msg("Alert escalated")
|
|
|
|
// Trigger escalation callback
|
|
if m.onEscalate != nil {
|
|
go m.onEscalate(alert, i+1)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Stop stops the alert manager and saves history
|
|
func (m *Manager) Stop() {
|
|
close(m.escalationStop)
|
|
m.historyManager.Stop()
|
|
// Save active alerts before stopping
|
|
if err := m.SaveActiveAlerts(); err != nil {
|
|
log.Error().Err(err).Msg("Failed to save active alerts on stop")
|
|
}
|
|
}
|
|
|
|
// SaveActiveAlerts persists active alerts to disk
|
|
func (m *Manager) SaveActiveAlerts() error {
|
|
m.mu.RLock()
|
|
defer m.mu.RUnlock()
|
|
|
|
// Create directory if it doesn't exist
|
|
alertsDir := filepath.Join(utils.GetDataDir(), "alerts")
|
|
if err := os.MkdirAll(alertsDir, 0755); err != nil {
|
|
return fmt.Errorf("failed to create alerts directory: %w", err)
|
|
}
|
|
|
|
// Convert map to slice for JSON encoding
|
|
alerts := make([]*Alert, 0, len(m.activeAlerts))
|
|
for _, alert := range m.activeAlerts {
|
|
alerts = append(alerts, alert)
|
|
}
|
|
|
|
data, err := json.MarshalIndent(alerts, "", " ")
|
|
if err != nil {
|
|
return fmt.Errorf("failed to marshal active alerts: %w", err)
|
|
}
|
|
|
|
// Write to temporary file first, then rename (atomic operation)
|
|
tmpFile := filepath.Join(alertsDir, "active-alerts.json.tmp")
|
|
finalFile := filepath.Join(alertsDir, "active-alerts.json")
|
|
|
|
if err := os.WriteFile(tmpFile, data, 0644); err != nil {
|
|
return fmt.Errorf("failed to write active alerts: %w", err)
|
|
}
|
|
|
|
if err := os.Rename(tmpFile, finalFile); err != nil {
|
|
return fmt.Errorf("failed to rename active alerts file: %w", err)
|
|
}
|
|
|
|
log.Info().Int("count", len(alerts)).Msg("Saved active alerts to disk")
|
|
return nil
|
|
}
|
|
|
|
// LoadActiveAlerts restores active alerts from disk
|
|
func (m *Manager) LoadActiveAlerts() error {
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
|
|
alertsFile := filepath.Join(utils.GetDataDir(), "alerts", "active-alerts.json")
|
|
data, err := os.ReadFile(alertsFile)
|
|
if err != nil {
|
|
if os.IsNotExist(err) {
|
|
log.Info().Msg("No active alerts file found, starting fresh")
|
|
return nil
|
|
}
|
|
return fmt.Errorf("failed to read active alerts: %w", err)
|
|
}
|
|
|
|
var alerts []*Alert
|
|
if err := json.Unmarshal(data, &alerts); err != nil {
|
|
return fmt.Errorf("failed to unmarshal active alerts: %w", err)
|
|
}
|
|
|
|
// Restore alerts to the map
|
|
now := time.Now()
|
|
restoredCount := 0
|
|
for _, alert := range alerts {
|
|
// Skip very old alerts (older than 24 hours)
|
|
if now.Sub(alert.StartTime) > 24*time.Hour {
|
|
log.Debug().Str("alertID", alert.ID).Msg("Skipping old alert during restore")
|
|
continue
|
|
}
|
|
|
|
// Skip acknowledged alerts older than 1 hour
|
|
if alert.Acknowledged && alert.AckTime != nil && now.Sub(*alert.AckTime) > time.Hour {
|
|
log.Debug().Str("alertID", alert.ID).Msg("Skipping old acknowledged alert")
|
|
continue
|
|
}
|
|
|
|
m.activeAlerts[alert.ID] = alert
|
|
restoredCount++
|
|
}
|
|
|
|
log.Info().Int("restored", restoredCount).Int("total", len(alerts)).Msg("Restored active alerts from disk")
|
|
return nil
|
|
}
|
|
|
|
// periodicSaveAlerts saves active alerts to disk periodically
|
|
func (m *Manager) periodicSaveAlerts() {
|
|
ticker := time.NewTicker(1 * time.Minute)
|
|
defer ticker.Stop()
|
|
|
|
for {
|
|
select {
|
|
case <-ticker.C:
|
|
if err := m.SaveActiveAlerts(); err != nil {
|
|
log.Error().Err(err).Msg("Failed to save active alerts during periodic save")
|
|
}
|
|
case <-m.escalationStop:
|
|
return
|
|
}
|
|
}
|
|
} |