Files
tyto/backend/internal/alerts/manager.go
vikingowl f4dbc55851 feat: add dashboard customization, alerts, PWA, and mobile support
Dashboard Editor & Layout:
- Full-screen visual editor for reorganizing cards
- Drag-and-drop cards between sections
- Toggle card visibility with persistence to localStorage
- Reset to default layout option

Alerts System:
- Threshold-based alerts for CPU, memory, temperature, disk, GPU
- Alert manager with duration requirements
- AlertsCard component with settings UI
- API endpoints for alerts CRUD

New Collectors:
- Docker container monitoring with parallel stats fetching
- Systemd service status via D-Bus
- Historical metrics storage (1 hour at 1s intervals)

PWA Support:
- Service worker with offline caching
- Web app manifest with SVG icons
- iOS PWA meta tags

Mobile Responsive:
- Collapsible hamburger menu on mobile
- Adaptive grid layouts for all screen sizes
- Touch-friendly hover states
- Safe area insets for notched devices

UI Enhancements:
- Light/dark theme toggle with persistence
- Keyboard shortcuts (T=theme, R=refresh, ?=help)
- Per-process expandable details in ProcessesCard
- Sparkline charts for historical data

Performance Fixes:
- Buffered SSE channels to prevent blocking
- Parallel Docker stats collection with timeout
- D-Bus timeout for systemd collector

Tests:
- Unit tests for CPU, memory, network collectors
- Alert manager tests

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-28 05:35:28 +01:00

212 lines
5.4 KiB
Go

package alerts
import (
"fmt"
"sync"
"time"
"system-monitor/internal/models"
)
// Manager handles alert threshold checking and tracking
type Manager struct {
mu sync.RWMutex
config models.AlertConfig
activeAlerts map[string]*models.Alert
alertHistory []models.Alert
thresholdHits map[string]time.Time // Track when threshold was first exceeded
maxHistory int
}
// NewManager creates a new alert manager with default config
func NewManager() *Manager {
return &Manager{
config: models.DefaultAlertConfig(),
activeAlerts: make(map[string]*models.Alert),
alertHistory: make([]models.Alert, 0),
thresholdHits: make(map[string]time.Time),
maxHistory: 100,
}
}
// GetConfig returns current alert configuration
func (m *Manager) GetConfig() models.AlertConfig {
m.mu.RLock()
defer m.mu.RUnlock()
return m.config
}
// SetConfig updates alert configuration
func (m *Manager) SetConfig(config models.AlertConfig) {
m.mu.Lock()
defer m.mu.Unlock()
m.config = config
}
// GetActiveAlerts returns all currently active alerts
func (m *Manager) GetActiveAlerts() []models.Alert {
m.mu.RLock()
defer m.mu.RUnlock()
alerts := make([]models.Alert, 0, len(m.activeAlerts))
for _, alert := range m.activeAlerts {
alerts = append(alerts, *alert)
}
return alerts
}
// GetAlertHistory returns recent alert history
func (m *Manager) GetAlertHistory() []models.Alert {
m.mu.RLock()
defer m.mu.RUnlock()
history := make([]models.Alert, len(m.alertHistory))
copy(history, m.alertHistory)
return history
}
// AcknowledgeAlert marks an alert as acknowledged
func (m *Manager) AcknowledgeAlert(alertID string) bool {
m.mu.Lock()
defer m.mu.Unlock()
// Search by alert ID field, not map key
for _, alert := range m.activeAlerts {
if alert.ID == alertID {
alert.Acknowledged = true
return true
}
}
return false
}
// CheckMetrics evaluates metrics against thresholds and triggers/resolves alerts
func (m *Manager) CheckMetrics(metrics models.AllMetrics) []models.Alert {
m.mu.Lock()
defer m.mu.Unlock()
var newAlerts []models.Alert
now := time.Now()
for _, threshold := range m.config.Thresholds {
if !threshold.Enabled {
continue
}
var value float64
var label string
switch threshold.Type {
case models.AlertTypeCPU:
value = metrics.CPU.TotalUsage
label = "CPU Usage"
case models.AlertTypeMemory:
if metrics.Memory.Total > 0 {
value = float64(metrics.Memory.Used) / float64(metrics.Memory.Total) * 100
}
label = "Memory Usage"
case models.AlertTypeTemperature:
// Find max temperature
for _, sensor := range metrics.Temperature.Sensors {
if sensor.Temperature > value {
value = sensor.Temperature
label = fmt.Sprintf("Temperature (%s)", sensor.Name)
}
}
if label == "" {
label = "Temperature"
}
case models.AlertTypeDisk:
// Find max disk usage
for _, mount := range metrics.Disk.Mounts {
if mount.UsedPercent > value {
value = mount.UsedPercent
label = fmt.Sprintf("Disk (%s)", mount.MountPoint)
}
}
if label == "" {
label = "Disk Usage"
}
case models.AlertTypeGPU:
if metrics.GPU.Available {
value = float64(metrics.GPU.Utilization)
label = "GPU Usage"
}
}
// Check thresholds
alertKey := string(threshold.Type)
var severity models.AlertSeverity
var thresholdValue float64
if value >= threshold.CriticalValue {
severity = models.AlertSeverityCritical
thresholdValue = threshold.CriticalValue
} else if value >= threshold.WarningValue {
severity = models.AlertSeverityWarning
thresholdValue = threshold.WarningValue
} else {
// Value is below thresholds - resolve any active alert
delete(m.thresholdHits, alertKey)
if alert, exists := m.activeAlerts[alertKey]; exists {
resolvedAt := now
alert.ResolvedAt = &resolvedAt
m.addToHistory(*alert)
delete(m.activeAlerts, alertKey)
}
continue
}
// Check duration requirement
firstHit, hasHit := m.thresholdHits[alertKey]
if !hasHit {
m.thresholdHits[alertKey] = now
if threshold.DurationSeconds > 0 {
continue // Wait for duration
}
firstHit = now
}
elapsed := now.Sub(firstHit)
if elapsed.Seconds() < float64(threshold.DurationSeconds) {
continue // Duration not met yet
}
// Check if we need to create/update alert
existingAlert, alertExists := m.activeAlerts[alertKey]
if !alertExists {
// Create new alert
alert := &models.Alert{
ID: fmt.Sprintf("%s-%d", threshold.Type, now.Unix()),
Type: threshold.Type,
Severity: severity,
Message: fmt.Sprintf("%s is at %.1f%% (threshold: %.1f%%)", label, value, thresholdValue),
Value: value,
Threshold: thresholdValue,
TriggeredAt: now,
}
m.activeAlerts[alertKey] = alert
newAlerts = append(newAlerts, *alert)
} else if existingAlert.Severity != severity {
// Severity changed - update alert
existingAlert.Severity = severity
existingAlert.Value = value
existingAlert.Threshold = thresholdValue
existingAlert.Message = fmt.Sprintf("%s is at %.1f%% (threshold: %.1f%%)", label, value, thresholdValue)
} else {
// Just update value
existingAlert.Value = value
}
}
return newAlerts
}
func (m *Manager) addToHistory(alert models.Alert) {
m.alertHistory = append([]models.Alert{alert}, m.alertHistory...)
if len(m.alertHistory) > m.maxHistory {
m.alertHistory = m.alertHistory[:m.maxHistory]
}
}