Extend owned-fleet control with scheduled tasks, audit log, file browser, HTTPS beacon when WS drops, protocol tunnels, registry/autostart forge options, KEV exposure in full sys check with Telegram alerts, and UI/tests.
240 lines
5.7 KiB
Go
240 lines
5.7 KiB
Go
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)
|
|
}
|