Threshold Alerts & macOS Notification Center Integration #24

Closed
opened 2026-09-08 10:29:48 +01:00 by gronod · 2 comments
Owner

Purpose

Detect abnormal system conditions and dispatch native macOS system notifications when configurable hardware thresholds are breached (e.g. CPU temperature exceeds safe limits, cooling fan failure, critical memory pressure, low disk space, or low battery).

Priority

Medium (Proactive protection and warning system for unattended hardware workloads)

Dependencies

  • Depends on #3 (CPU Core Temperatures & Thermal Zone Monitoring)
  • Depends on #4 (Fan Speed & Multi-Fan Telemetry)
  • Depends on #8 (RAM Breakdown, Compression & Memory Pressure Monitoring)
  • Depends on #9 (Storage Volumes, APFS Containers & Capacity Telemetry)

Scope

  • Configurable threshold triggers in Preferences:
    • High CPU temperature (e.g. > 95°C)
    • Fan failure (e.g. 0 RPM detected when CPU temp > 75°C)
    • High memory pressure / low free memory (< 500 MB)
    • Storage volume critical threshold (< 10% or < 10 GB free)
    • Low battery alert (< 10% on MacBook)
  • Native macOS banner and alert notifications via UserNotifications.
  • Anti-spam and notification cooldown logic (e.g. at most one alert per 15 minutes for the same condition until resolved).
  • In-app visual warning indicators and alerts history log.

Implementation Suggestions

  • Implement a rule evaluation engine (AlertEngine) that inspects telemetry snapshots after every sampling pass.
  • Request user permission via UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .sound]).
  • Maintain a cooldown timestamp dictionary per alert identifier to prevent spamming notifications.
  • Deliver localized notifications with clear actions (e.g. "View Processes", "Open MacMonitor").
### Purpose Detect abnormal system conditions and dispatch native macOS system notifications when configurable hardware thresholds are breached (e.g. CPU temperature exceeds safe limits, cooling fan failure, critical memory pressure, low disk space, or low battery). ### Priority **Medium** (Proactive protection and warning system for unattended hardware workloads) ### Dependencies - Depends on #3 (CPU Core Temperatures & Thermal Zone Monitoring) - Depends on #4 (Fan Speed & Multi-Fan Telemetry) - Depends on #8 (RAM Breakdown, Compression & Memory Pressure Monitoring) - Depends on #9 (Storage Volumes, APFS Containers & Capacity Telemetry) ### Scope - Configurable threshold triggers in Preferences: - High CPU temperature (e.g. > 95°C) - Fan failure (e.g. 0 RPM detected when CPU temp > 75°C) - High memory pressure / low free memory (< 500 MB) - Storage volume critical threshold (< 10% or < 10 GB free) - Low battery alert (< 10% on MacBook) - Native macOS banner and alert notifications via `UserNotifications`. - Anti-spam and notification cooldown logic (e.g. at most one alert per 15 minutes for the same condition until resolved). - In-app visual warning indicators and alerts history log. ### Implementation Suggestions - Implement a rule evaluation engine (`AlertEngine`) that inspects telemetry snapshots after every sampling pass. - Request user permission via `UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .sound])`. - Maintain a cooldown timestamp dictionary per alert identifier to prevent spamming notifications. - Deliver localized notifications with clear actions (e.g. "View Processes", "Open MacMonitor").
Author
Owner

Technical Implementation Plan & Code Solution

1. Alert Engine Architecture

The Alert Engine runs synchronously after each telemetry digest pass, evaluating threshold conditions against configured limits and applying cooldown timers to prevent notification storms.

flowchart TD
    SNAP["Telemetry Snapshot"] --> EVAL["AlertEngine.evaluateRules()"]
    EVAL --> CD{"Cooldown Check<br/>(e.g. max 1 alert per 15 min)"}
    CD -->|"Within Cooldown"| SUPP["Suppress Duplicate Notification"]
    CD -->|"Cooldown Expired & Threshold Breached"| NOTIF["UNUserNotificationCenter.add(request)"]
    NOTIF --> BANNER["macOS Native Banner / Alert"]

2. Swift Implementation (AlertEngine.swift)

import UserNotifications
import Foundation

public struct AlertRule: Sendable {
    public let id: String
    public let title: String
    public let cooldownInterval: TimeInterval
    public let condition: @Sendable (SystemTelemetryStore) -> (triggered: Bool, message: String)
}

public final class AlertEngine: @unchecked Sendable {
    public static let shared = AlertEngine()
    private var lastTriggered: [String: Date] = [:]
    private let lock = NSLock()

    public func requestNotificationPermission() {
        UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .sound, .badge]) { granted, error in
            // Handle authorization
        }
    }

    public func defaultRules() -> [AlertRule] {
        return [
            AlertRule(id: "cpu_temp_critical", title: "CPU Overheating Alert", cooldownInterval: 600) { store in
                let temp = store.thermals.packageTemperature
                return (temp >= 95.0, String(format: "CPU Package temperature has reached %.1f°C! Active throttling in progress.", temp))
            },
            AlertRule(id: "fan_stall", title: "Cooling Fan Stalled", cooldownInterval: 300) { store in
                let stalled = store.fans.items.contains { $0.isStalled }
                return (stalled, "One or more cooling fans have stopped spinning while system is active.")
            },
            AlertRule(id: "memory_critical", title: "Critical Memory Pressure", cooldownInterval: 600) { store in
                return (store.memory.pressureLevel == 4, "System is facing critical memory exhaustion. Active paging detected.")
            },
            AlertRule(id: "disk_full", title: "Storage Running Out", cooldownInterval: 1800) { store in
                let nearlyFull = store.disks.contains { $0.isRoot && $0.usedPercentage > 92.0 }
                return (nearlyFull, "Boot volume free space is critically low (>92% utilized).")
            }
        ]
    }

    public func evaluate(store: SystemTelemetryStore) {
        let now = Date()
        for rule in defaultRules() {
            let result = rule.condition(store)
            guard result.triggered else { continue }

            lock.lock()
            let last = lastTriggered[rule.id]
            if let last = last, now.timeIntervalSince(last) < rule.cooldownInterval {
                lock.unlock()
                continue
            }
            lastTriggered[rule.id] = now
            lock.unlock()

            self.dispatchNotification(title: rule.title, body: result.message, identifier: rule.id)
        }
    }

    private func dispatchNotification(title: String, body: String, identifier: String) {
        let content = UNMutableNotificationContent()
        content.title = title
        content.body = body
        content.sound = .defaultCritical

        let request = UNNotificationRequest(
            identifier: "\(identifier)-\(Date().timeIntervalSince1970)",
            content: content,
            trigger: nil // Deliver immediately
        )

        UNUserNotificationCenter.current().add(request, withCompletionHandler: nil)
    }
}

3. Anti-Spam & Hysteresis

  • Cooldown timestamps stored per rule ID prevent repeat notifications from triggering on every 1-second sampling cycle.
  • Hysteresis thresholds ensure fluctuating values around boundary edges don't cause notification flickering.
## Technical Implementation Plan & Code Solution ### 1. Alert Engine Architecture The Alert Engine runs synchronously after each telemetry digest pass, evaluating threshold conditions against configured limits and applying cooldown timers to prevent notification storms. ```mermaid flowchart TD SNAP["Telemetry Snapshot"] --> EVAL["AlertEngine.evaluateRules()"] EVAL --> CD{"Cooldown Check<br/>(e.g. max 1 alert per 15 min)"} CD -->|"Within Cooldown"| SUPP["Suppress Duplicate Notification"] CD -->|"Cooldown Expired & Threshold Breached"| NOTIF["UNUserNotificationCenter.add(request)"] NOTIF --> BANNER["macOS Native Banner / Alert"] ``` --- ### 2. Swift Implementation (`AlertEngine.swift`) ```swift import UserNotifications import Foundation public struct AlertRule: Sendable { public let id: String public let title: String public let cooldownInterval: TimeInterval public let condition: @Sendable (SystemTelemetryStore) -> (triggered: Bool, message: String) } public final class AlertEngine: @unchecked Sendable { public static let shared = AlertEngine() private var lastTriggered: [String: Date] = [:] private let lock = NSLock() public func requestNotificationPermission() { UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .sound, .badge]) { granted, error in // Handle authorization } } public func defaultRules() -> [AlertRule] { return [ AlertRule(id: "cpu_temp_critical", title: "CPU Overheating Alert", cooldownInterval: 600) { store in let temp = store.thermals.packageTemperature return (temp >= 95.0, String(format: "CPU Package temperature has reached %.1f°C! Active throttling in progress.", temp)) }, AlertRule(id: "fan_stall", title: "Cooling Fan Stalled", cooldownInterval: 300) { store in let stalled = store.fans.items.contains { $0.isStalled } return (stalled, "One or more cooling fans have stopped spinning while system is active.") }, AlertRule(id: "memory_critical", title: "Critical Memory Pressure", cooldownInterval: 600) { store in return (store.memory.pressureLevel == 4, "System is facing critical memory exhaustion. Active paging detected.") }, AlertRule(id: "disk_full", title: "Storage Running Out", cooldownInterval: 1800) { store in let nearlyFull = store.disks.contains { $0.isRoot && $0.usedPercentage > 92.0 } return (nearlyFull, "Boot volume free space is critically low (>92% utilized).") } ] } public func evaluate(store: SystemTelemetryStore) { let now = Date() for rule in defaultRules() { let result = rule.condition(store) guard result.triggered else { continue } lock.lock() let last = lastTriggered[rule.id] if let last = last, now.timeIntervalSince(last) < rule.cooldownInterval { lock.unlock() continue } lastTriggered[rule.id] = now lock.unlock() self.dispatchNotification(title: rule.title, body: result.message, identifier: rule.id) } } private func dispatchNotification(title: String, body: String, identifier: String) { let content = UNMutableNotificationContent() content.title = title content.body = body content.sound = .defaultCritical let request = UNNotificationRequest( identifier: "\(identifier)-\(Date().timeIntervalSince1970)", content: content, trigger: nil // Deliver immediately ) UNUserNotificationCenter.current().add(request, withCompletionHandler: nil) } } ``` --- ### 3. Anti-Spam & Hysteresis - Cooldown timestamps stored per rule ID prevent repeat notifications from triggering on every 1-second sampling cycle. - Hysteresis thresholds ensure fluctuating values around boundary edges don't cause notification flickering.
gronod added this to the M5: Intelligence, Alerts & Hardening milestone 2026-09-08 10:42:53 +01:00
gronod added a new dependency 2026-09-08 11:37:11 +01:00
gronod added a new dependency 2026-09-08 11:37:11 +01:00
gronod added a new dependency 2026-09-08 11:37:11 +01:00
gronod added a new dependency 2026-09-08 11:37:11 +01:00
Author
Owner

Implemented in PR #26 targeting milestone/m5-alerts-hardening.

Delivered

  • AlertEngine (@Observable, @MainActor) evaluating all five threshold rules — CPU temperature, fan stall, low memory/critical pressure, per-volume low storage, low battery — against a Sendable AlertEvaluationContext after each sample pass.
  • UNUserNotificationCenter delivery behind a mockable AlertNotificationDispatching protocol; authorization requested at launch; MM_ALERT category with Open MacMonitor / View Processes actions routed via AppNavigationBus.
  • Anti-spam: per-alert cooldown (15 min default, configurable), edge-triggered resolve detection, immediate re-alert after recovery.
  • In-app indicators: new Alerts & Notifications sidebar tab (active alerts + 200-event history log), header warning badge, menu bar ⚠ indicator.
  • Settings scene (AlertsSettingsView) for per-rule toggles, thresholds, cooldown, permission status, and test notification.

Verified

  • xcodebuild build test -scheme MacMonitor -destination 'platform=macOS,arch=x86_64' — 87 tests, 0 failures (25 new alert tests).
  • swiftlint lint — 0 violations.
Implemented in PR #26 targeting `milestone/m5-alerts-hardening`. ### Delivered - `AlertEngine` (@Observable, @MainActor) evaluating all five threshold rules — CPU temperature, fan stall, low memory/critical pressure, per-volume low storage, low battery — against a Sendable `AlertEvaluationContext` after each sample pass. - `UNUserNotificationCenter` delivery behind a mockable `AlertNotificationDispatching` protocol; authorization requested at launch; `MM_ALERT` category with **Open MacMonitor** / **View Processes** actions routed via `AppNavigationBus`. - Anti-spam: per-alert cooldown (15 min default, configurable), edge-triggered resolve detection, immediate re-alert after recovery. - In-app indicators: new **Alerts & Notifications** sidebar tab (active alerts + 200-event history log), header warning badge, menu bar ⚠ indicator. - **Settings** scene (`AlertsSettingsView`) for per-rule toggles, thresholds, cooldown, permission status, and test notification. ### Verified - `xcodebuild build test -scheme MacMonitor -destination 'platform=macOS,arch=x86_64'` — 87 tests, 0 failures (25 new alert tests). - `swiftlint lint` — 0 violations.
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Reference: gronod/MacMonitor#24