Files
MacMonitor/Sources/UI/Settings/AlertsSettingsView.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

166 lines
6.9 KiB
Swift

import SwiftUI
import UserNotifications
// MARK: - Alerts Settings Pane
/// Preferences UI for threshold alert rules. Bound directly to
/// `AlertEngine.shared.configuration`, which auto-persists to `UserDefaults`.
public struct AlertsSettingsView: View {
@Bindable private var engine = AlertEngine.shared
@State private var authorizationStatus: UNAuthorizationStatus = .notDetermined
public init() {}
public var body: some View {
Form {
Section("Notifications") {
Toggle("Enable Threshold Alerts", isOn: $engine.configuration.alertsEnabled)
HStack {
Text("Notification Permission")
Spacer()
Text(statusText)
.foregroundStyle(statusColor)
if authorizationStatus == .notDetermined {
Button("Request Permission") {
engine.requestNotificationAuthorization()
refreshAuthorizationStatus()
}
} else if authorizationStatus == .denied {
Button("Open System Settings") {
if let url = URL(string: "x-apple.systempreferences:com.apple.preference.notifications") {
NSWorkspace.shared.open(url)
}
}
}
}
Button("Send Test Notification") {
engine.dispatcher.dispatch(SystemAlert(
id: "testNotification",
kind: .cpuTemperature,
severity: .warning,
title: String(localized: "MacMonitor Test Alert"),
message: String(localized: "Notifications are configured correctly."),
timestamp: Date()
))
}
.disabled(!engine.configuration.alertsEnabled)
Picker("Alert Cooldown", selection: $engine.configuration.cooldownMinutes) {
Text("1 minute").tag(1.0)
Text("5 minutes").tag(5.0)
Text("15 minutes").tag(15.0)
Text("30 minutes").tag(30.0)
Text("1 hour").tag(60.0)
}
}
Section("CPU Temperature") {
Toggle("High CPU Temperature Alert", isOn: $engine.configuration.cpuTempEnabled)
LabeledContent("Threshold") {
Slider(value: $engine.configuration.cpuTempThresholdC, in: 60...110, step: 1) {
EmptyView()
}
Text("\(Int(engine.configuration.cpuTempThresholdC))°C")
.monospacedDigit()
.frame(width: 48, alignment: .trailing)
}
}
Section("Cooling Fans") {
Toggle("Fan Stall Alert", isOn: $engine.configuration.fanStallEnabled)
LabeledContent("Alert when CPU above") {
Slider(value: $engine.configuration.fanStallTempThresholdC, in: 40...100, step: 1) {
EmptyView()
}
Text("\(Int(engine.configuration.fanStallTempThresholdC))°C")
.monospacedDigit()
.frame(width: 48, alignment: .trailing)
}
Text("Triggers when a fan reports 0 RPM while the CPU is hot.")
.font(.caption)
.foregroundStyle(.secondary)
}
Section("Memory") {
Toggle("Low Memory Alert", isOn: $engine.configuration.memoryEnabled)
LabeledContent("Free RAM below") {
Slider(value: $engine.configuration.memoryFreeMBThreshold, in: 100...4096, step: 50) {
EmptyView()
}
Text("\(Int(engine.configuration.memoryFreeMBThreshold)) MB")
.monospacedDigit()
.frame(width: 64, alignment: .trailing)
}
Toggle("Alert on Critical Memory Pressure", isOn: $engine.configuration.memoryAlertOnCriticalPressure)
}
Section("Storage") {
Toggle("Low Disk Space Alert", isOn: $engine.configuration.storageEnabled)
LabeledContent("Free space below") {
Slider(value: $engine.configuration.storageFreeGBThreshold, in: 1...100, step: 1) {
EmptyView()
}
Text("\(Int(engine.configuration.storageFreeGBThreshold)) GB")
.monospacedDigit()
.frame(width: 48, alignment: .trailing)
}
LabeledContent("Free percent below") {
Slider(value: $engine.configuration.storageFreePercentThreshold, in: 1...50, step: 1) {
EmptyView()
}
Text("\(Int(engine.configuration.storageFreePercentThreshold))%")
.monospacedDigit()
.frame(width: 48, alignment: .trailing)
}
}
Section("Battery") {
Toggle("Low Battery Alert", isOn: $engine.configuration.batteryEnabled)
LabeledContent("Charge below") {
Slider(value: $engine.configuration.batteryPercentThreshold, in: 1...50, step: 1) {
EmptyView()
}
Text("\(Int(engine.configuration.batteryPercentThreshold))%")
.monospacedDigit()
.frame(width: 48, alignment: .trailing)
}
Text("Only fires while discharging on battery power.")
.font(.caption)
.foregroundStyle(.secondary)
}
}
.formStyle(.grouped)
.frame(minWidth: 480, minHeight: 520)
.task { await refreshAuthorizationStatus() }
}
private var statusText: String {
switch authorizationStatus {
case .authorized, .provisional, .ephemeral: return "Authorized"
case .denied: return "Denied"
case .notDetermined: return "Not Requested"
@unknown default: return "Unknown"
}
}
private var statusColor: Color {
switch authorizationStatus {
case .authorized, .provisional, .ephemeral: return .green
case .denied: return .red
default: return .secondary
}
}
private func refreshAuthorizationStatus() {
Task {
authorizationStatus = await engine.notificationAuthorizationStatus()
}
}
private func refreshAuthorizationStatus() async {
authorizationStatus = await engine.notificationAuthorizationStatus()
}
}