package alerts import ( "fmt" "log" "sync" "time" "crypto-miner-server/internal/db" "crypto-miner-server/internal/models" ) type AlertEvent struct { ID string `json:"id"` Level string `json:"level"` // warn, error Type string `json:"type"` // offline, hashrate_drop, rejection_rate AgentID string `json:"agent_id,omitempty"` AgentName string `json:"agent_name,omitempty"` Message string `json:"message"` Timestamp time.Time `json:"timestamp"` } type Thresholds struct { OfflineMinutes int HashrateDropPct int RejectionRatePct int } type Broadcaster func(AlertEvent) type Evaluator struct { db *db.Database thresholds func() Thresholds settings func() Settings broadcast Broadcaster mu sync.Mutex baseline map[string]float64 lastFired map[string]time.Time activeAlerts []AlertEvent cooldown time.Duration } func NewEvaluator(database *db.Database, thresholds func() Thresholds, settings func() Settings, broadcast Broadcaster) *Evaluator { return &Evaluator{ db: database, thresholds: thresholds, settings: settings, broadcast: broadcast, baseline: make(map[string]float64), lastFired: make(map[string]time.Time), activeAlerts: make([]AlertEvent, 0, 32), cooldown: 10 * time.Minute, } } func (e *Evaluator) Start(interval time.Duration) { go func() { ticker := time.NewTicker(interval) defer ticker.Stop() for range ticker.C { e.RunOnce() } }() } func (e *Evaluator) RunOnce() { agents, err := e.db.ListAgents() if err != nil { return } th := e.thresholds() now := time.Now() for _, a := range agents { e.checkOffline(a, th, now) e.checkHashrateDrop(a, th) e.checkRejection(a, th) if a.Status == "online" && a.Hashrate15m > 0 { e.mu.Lock() e.baseline[a.ID] = a.Hashrate15m e.mu.Unlock() } } } func (e *Evaluator) checkOffline(a *models.Agent, th Thresholds, now time.Time) { if th.OfflineMinutes <= 0 { return } offline := a.Status != "online" || now.Sub(a.LastSeen) > time.Duration(th.OfflineMinutes)*time.Minute if !offline { return } key := "offline:" + a.ID if e.inCooldown(key) { return } ev := AlertEvent{ ID: key + ":" + now.Format("20060102150405"), Level: "error", Type: "offline", AgentID: a.ID, AgentName: a.Name, Message: a.Name + " offline or not seen for " + time.Since(a.LastSeen).Round(time.Minute).String(), Timestamp: now, } e.fire(ev, key) } func (e *Evaluator) checkHashrateDrop(a *models.Agent, th Thresholds) { if th.HashrateDropPct <= 0 || a.Status != "online" { return } e.mu.Lock() base := e.baseline[a.ID] e.mu.Unlock() if base <= 0 || a.Hashrate15m <= 0 { return } dropPct := (base - a.Hashrate15m) / base * 100 if dropPct < float64(th.HashrateDropPct) { return } key := "hashrate:" + a.ID if e.inCooldown(key) { return } ev := AlertEvent{ ID: key + ":" + time.Now().Format("20060102150405"), Level: "warn", Type: "hashrate_drop", AgentID: a.ID, AgentName: a.Name, Message: a.Name + " hashrate dropped " + formatPct(dropPct) + "% vs baseline", Timestamp: time.Now(), } e.fire(ev, key) } func (e *Evaluator) checkRejection(a *models.Agent, th Thresholds) { if th.RejectionRatePct <= 0 || a.SharesTotal < 5 { return } rejectPct := float64(a.SharesBad) / float64(a.SharesTotal) * 100 if rejectPct < float64(th.RejectionRatePct) { return } key := "reject:" + a.ID if e.inCooldown(key) { return } ev := AlertEvent{ ID: key + ":" + time.Now().Format("20060102150405"), Level: "warn", Type: "rejection_rate", AgentID: a.ID, AgentName: a.Name, Message: a.Name + " rejection rate " + formatPct(rejectPct) + "% exceeds threshold", Timestamp: time.Now(), } e.fire(ev, key) } func (e *Evaluator) inCooldown(key string) bool { e.mu.Lock() defer e.mu.Unlock() if t, ok := e.lastFired[key]; ok && time.Since(t) < e.cooldown { return true } return false } func (e *Evaluator) fire(ev AlertEvent, cooldownKey string) { e.mu.Lock() e.lastFired[cooldownKey] = time.Now() e.activeAlerts = append([]AlertEvent{ev}, e.activeAlerts...) if len(e.activeAlerts) > 50 { e.activeAlerts = e.activeAlerts[:50] } e.mu.Unlock() log.Printf("[Alert] %s: %s", ev.Type, ev.Message) s := e.settings() if s.EnabledForAlertType(ev.Type) { NotifyAllEvent(s.NotifyConfig, ev.Type, "AetherForge "+ev.Type, ev.Message) } if e.broadcast != nil { e.broadcast(ev) } } // GetNotifyConfig returns delivery credentials for channel probes. func (e *Evaluator) GetNotifyConfig() NotifyConfig { return e.settings().NotifyConfig } // GetSettings returns full notification settings. func (e *Evaluator) GetSettings() Settings { return e.settings() } func (e *Evaluator) ActiveAlerts() []AlertEvent { e.mu.Lock() defer e.mu.Unlock() out := make([]AlertEvent, len(e.activeAlerts)) copy(out, e.activeAlerts) return out } // ClearAgent removes all in-memory alert state for a specific agent. // Call this when an agent is deleted so stale alerts stop appearing on the // dashboard for machines that no longer exist. func (e *Evaluator) ClearAgent(agentID string) { e.mu.Lock() defer e.mu.Unlock() // Remove cached alerts for this agent. filtered := e.activeAlerts[:0] for _, a := range e.activeAlerts { if a.AgentID != agentID { filtered = append(filtered, a) } } e.activeAlerts = filtered // Remove cooldown + baseline entries so the agent's next appearance // (e.g. re-registration) starts fresh. delete(e.baseline, agentID) for k := range e.lastFired { if len(k) > len(agentID) && k[len(k)-len(agentID):] == agentID { delete(e.lastFired, k) } } } func formatPct(v float64) string { if v < 0 { v = 0 } return fmt.Sprintf("%.1f", v) }