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>
166 lines
6.9 KiB
Swift
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()
|
|
}
|
|
}
|