Files
MacMonitor/Sources/Intelligence/AlertEngine.swift
gronodandDevin <158243242+devin-ai-integration[bot]@users.noreply.github.com> a7f13542ae feat(alerts): implement threshold alert engine and Notification Center integration (fixes #24)
Introduce Sources/Intelligence with an @Observable AlertEngine that evaluates
five configurable rules (CPU temperature, fan stall, low memory, low storage,
low battery) against a Sendable AlertEvaluationContext built after each
telemetry decode pass. Breached conditions dispatch through a mockable
AlertNotificationDispatching protocol to UNUserNotificationCenter with
per-alert cooldowns, edge-triggered resolve detection, and a capped
in-memory history log.

Add a macOS Settings scene (AlertsSettingsView) for thresholds, cooldown,
permission status, and test notifications; a new Alerts and Notifications
sidebar tab with active-alert cards and the history log; header and menu bar
warning indicators; and an AppNavigationBus so notification actions
(Open MacMonitor, View Processes) steer the main window.

Also fix the memory snapshot decode to read memoryPressureStatus so
pressureLevel is populated correctly.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-09-08 13:53:48 +01:00

228 lines
8.7 KiB
Swift

import Foundation
import UserNotifications
// MARK: - Alert Engine
/// Evaluates threshold rules against each decoded telemetry snapshot.
/// Runs on `@MainActor` — it performs only in-memory comparisons over
/// `AlertEvaluationContext` and never touches IOKit/Mach/sysctl, preserving
/// the zero-UI-blocking invariant.
///
/// Lifecycle per alert identifier:
/// breach edge -> fire notification (subject to cooldown), log `.triggered`
/// persistent -> re-fire only after `cooldownMinutes` elapse
/// recovery -> remove from active set, log `.resolved`, clear cooldown so
/// the next breach alerts immediately
@Observable
@MainActor
public final class AlertEngine {
public static let shared = AlertEngine()
/// Currently breached conditions, refreshed every evaluation pass.
public private(set) var activeAlerts: [SystemAlert] = []
/// Newest-first audit log of trigger/resolve events (capped).
public private(set) var history: [AlertEvent] = []
/// Threshold settings. Mutations are persisted immediately.
public var configuration: AlertConfiguration {
didSet { configuration.save(to: defaults) }
}
public let dispatcher: any AlertNotificationDispatching
private let defaults: UserDefaults
private let historyLimit = 200
private var lastFiredAt: [String: Date] = [:]
private var breachedAlerts: [String: SystemAlert] = [:]
private convenience init() {
self.init(dispatcher: UNAlertNotificationDispatcher(), defaults: .standard)
}
init(dispatcher: any AlertNotificationDispatching, defaults: UserDefaults) {
self.dispatcher = dispatcher
self.defaults = defaults
self.configuration = AlertConfiguration.load(from: defaults)
}
/// Forwards to the dispatcher; call once at launch (and from Settings).
public func requestNotificationAuthorization() {
dispatcher.requestAuthorization()
}
public func notificationAuthorizationStatus() async -> UNAuthorizationStatus {
await dispatcher.authorizationStatus()
}
// MARK: Evaluation
public func evaluate(_ context: AlertEvaluationContext) {
guard configuration.alertsEnabled else {
// Wipe silently so stale alerts do not linger while disabled.
activeAlerts = []
breachedAlerts = [:]
lastFiredAt = [:]
return
}
let candidates = buildCandidates(for: context)
var currentIDs = Set<String>()
for alert in candidates {
currentIDs.insert(alert.id)
breachedAlerts[alert.id] = alert
if shouldFire(alertID: alert.id, at: context.timestamp) {
fire(alert, at: context.timestamp)
}
}
let resolvedIDs = breachedAlerts.keys
.filter { !currentIDs.contains($0) }
.sorted()
for id in resolvedIDs {
let resolved = breachedAlerts.removeValue(forKey: id)
lastFiredAt.removeValue(forKey: id)
let kind = resolved?.kind ?? Self.kind(fromAlertID: id)
appendEvent(
alertID: id,
kind: kind,
outcome: .resolved,
message: String(localized: "\(resolved?.title ?? id) — condition cleared"),
at: context.timestamp
)
}
activeAlerts = candidates
}
/// Clears all active/breached state and the audit log.
public func reset() {
activeAlerts = []
history = []
breachedAlerts = [:]
lastFiredAt = [:]
}
public func clearHistory() {
history = []
}
// MARK: Rule Evaluation
private func buildCandidates(for ctx: AlertEvaluationContext) -> [SystemAlert] {
let cfg = configuration
var alerts: [SystemAlert] = []
// 1. High CPU temperature
if cfg.cpuTempEnabled {
let temp = max(ctx.peakCoreTemperature, ctx.packageTemperature)
if temp >= cfg.cpuTempThresholdC {
alerts.append(SystemAlert(
id: AlertKind.cpuTemperature.rawValue,
kind: .cpuTemperature,
severity: .critical,
title: String(localized: "High CPU Temperature"),
message: String(localized: "CPU reached \(Int(temp))°C — threshold is \(Int(cfg.cpuTempThresholdC))°C"),
timestamp: ctx.timestamp
))
}
}
// 2. Cooling fan stall — only meaningful when fans are reported and the
// CPU is hot enough that a working fan should be spinning.
if cfg.fanStallEnabled,
!ctx.fanRPMs.isEmpty,
ctx.packageTemperature >= cfg.fanStallTempThresholdC {
for (index, rpm) in ctx.fanRPMs.sorted(by: { $0.key < $1.key }) where rpm <= 0 {
alerts.append(SystemAlert(
id: "\(AlertKind.fanStall.rawValue):\(index)",
kind: .fanStall,
severity: .critical,
title: String(localized: "Cooling Fan Failure"),
message: String(localized: "Fan \(index + 1) reads 0 RPM while the CPU is at \(Int(ctx.packageTemperature))°C"),
timestamp: ctx.timestamp
))
}
}
// 3. Low free memory / critical pressure
if cfg.memoryEnabled {
let freeMB = Double(ctx.memoryFreeBytes) / (1024 * 1024)
let critical = cfg.memoryAlertOnCriticalPressure
&& ctx.memoryPressureLevel.localizedCaseInsensitiveContains("critical")
if freeMB < cfg.memoryFreeMBThreshold || critical {
alerts.append(SystemAlert(
id: AlertKind.memoryPressure.rawValue,
kind: .memoryPressure,
severity: critical ? .critical : .warning,
title: String(localized: "Memory Pressure"),
message: String(localized: "Only \(Int(freeMB)) MB RAM free — pressure is \(ctx.memoryPressureLevel)"),
timestamp: ctx.timestamp
))
}
}
// 4. Low storage space (per mounted volume)
if cfg.storageEnabled {
for volume in ctx.volumes {
let freeGB = Double(volume.freeBytes) / (1024 * 1024 * 1024)
if freeGB < cfg.storageFreeGBThreshold || volume.freePercent < cfg.storageFreePercentThreshold {
alerts.append(SystemAlert(
id: "\(AlertKind.lowStorage.rawValue):\(volume.mountPoint)",
kind: .lowStorage,
severity: .warning,
title: String(localized: "Low Disk Space"),
message: String(localized: "\"\(volume.volumeName)\" has \(String(format: "%.1f", freeGB)) GB free (\(Int(volume.freePercent))%)"),
timestamp: ctx.timestamp
))
}
}
}
// 5. Low battery — only while actually discharging on battery power.
if cfg.batteryEnabled,
ctx.hasBattery,
!ctx.onExternalPower,
!ctx.isCharging,
ctx.batteryLevel <= cfg.batteryPercentThreshold {
alerts.append(SystemAlert(
id: AlertKind.lowBattery.rawValue,
kind: .lowBattery,
severity: .critical,
title: String(localized: "Low Battery"),
message: String(localized: "Battery is at \(Int(ctx.batteryLevel))% and not charging"),
timestamp: ctx.timestamp
))
}
return alerts
}
// MARK: Cooldown & History
private func shouldFire(alertID: String, at date: Date) -> Bool {
guard let last = lastFiredAt[alertID] else { return true }
return date.timeIntervalSince(last) >= configuration.cooldownMinutes * 60
}
private func fire(_ alert: SystemAlert, at date: Date) {
lastFiredAt[alert.id] = date
dispatcher.dispatch(alert)
appendEvent(alertID: alert.id, kind: alert.kind, outcome: .triggered, message: alert.message, at: date)
}
private func appendEvent(alertID: String, kind: AlertKind, outcome: AlertEventOutcome, message: String, at date: Date) {
history.insert(
AlertEvent(timestamp: date, alertID: alertID, kind: kind, outcome: outcome, message: message),
at: 0
)
if history.count > historyLimit {
history.removeLast(history.count - historyLimit)
}
}
private static func kind(fromAlertID id: String) -> AlertKind {
let prefix = id.prefix { $0 != ":" }
return AlertKind(rawValue: String(prefix)) ?? .cpuTemperature
}
}