Files
MacMonitor/Sources/UI/Alerts/AlertsView.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

164 lines
6.1 KiB
Swift

import SwiftUI
// MARK: - Alerts & Notifications View
/// Sidebar tab showing currently breached thresholds and the in-app
/// alert history log maintained by `AlertEngine`.
public struct AlertsView: View {
@State private var engine = AlertEngine.shared
public init() {}
public var body: some View {
VStack(alignment: .leading, spacing: 20) {
activeAlertsCard
historyCard
}
}
// MARK: - Active Alerts
private var activeAlertsCard: some View {
VStack(alignment: .leading, spacing: 12) {
HStack {
Label("Active Alerts", systemImage: "exclamationmark.triangle.fill")
.font(.headline)
Spacer()
if engine.activeAlerts.isEmpty {
Text("ALL CLEAR")
.font(.caption.bold())
.foregroundStyle(.green)
.padding(.horizontal, 6)
.padding(.vertical, 2)
.background(Color.green.opacity(0.15), in: Capsule())
} else {
Text("\(engine.activeAlerts.count) BREACHED")
.font(.caption.bold())
.foregroundStyle(.red)
.padding(.horizontal, 6)
.padding(.vertical, 2)
.background(Color.red.opacity(0.15), in: Capsule())
}
}
if engine.activeAlerts.isEmpty {
Text("All monitored thresholds are within normal limits.")
.font(.caption)
.foregroundStyle(.secondary)
} else {
ForEach(engine.activeAlerts) { alert in
HStack(alignment: .top, spacing: 10) {
Image(systemName: iconName(for: alert.kind))
.foregroundStyle(color(for: alert.severity))
.frame(width: 20)
VStack(alignment: .leading, spacing: 2) {
Text(alert.title)
.font(.caption.bold())
Text(alert.message)
.font(.caption2)
.foregroundStyle(.secondary)
}
Spacer()
VStack(alignment: .trailing, spacing: 2) {
Text(alert.severity.rawValue.uppercased())
.font(.caption2.bold())
.foregroundStyle(color(for: alert.severity))
Text(alert.timestamp.formatted(date: .omitted, time: .standard))
.font(.caption2)
.foregroundStyle(.secondary)
}
}
.padding(8)
.background(color(for: alert.severity).opacity(0.08), in: RoundedRectangle(cornerRadius: 8))
}
}
}
.padding(16)
.background(.background, in: RoundedRectangle(cornerRadius: 12))
.overlay(RoundedRectangle(cornerRadius: 12).stroke(.separator, lineWidth: 1))
}
// MARK: - History Log
private var historyCard: some View {
VStack(alignment: .leading, spacing: 12) {
HStack {
Label("Alert History", systemImage: "clock.arrow.circlepath")
.font(.headline)
Spacer()
Button("Clear History") {
engine.clearHistory()
}
.buttonStyle(.bordered)
.disabled(engine.history.isEmpty)
}
if engine.history.isEmpty {
Text("No alert events recorded yet.")
.font(.caption)
.foregroundStyle(.secondary)
} else {
ForEach(engine.history) { event in
HStack(alignment: .top, spacing: 10) {
Image(systemName: outcomeIcon(event.outcome))
.foregroundStyle(outcomeColor(event.outcome))
.frame(width: 16)
VStack(alignment: .leading, spacing: 2) {
Text(event.message)
.font(.caption)
Text(event.alertID)
.font(.caption2)
.foregroundStyle(.secondary)
.monospaced()
}
Spacer()
Text(event.timestamp.formatted(date: .abbreviated, time: .standard))
.font(.caption2)
.foregroundStyle(.secondary)
}
.padding(.vertical, 2)
Divider()
}
}
}
.padding(16)
.background(.background, in: RoundedRectangle(cornerRadius: 12))
.overlay(RoundedRectangle(cornerRadius: 12).stroke(.separator, lineWidth: 1))
}
// MARK: - Presentation Helpers
private func iconName(for kind: AlertKind) -> String {
switch kind {
case .cpuTemperature: return "thermometer.high"
case .fanStall: return "fanblades"
case .memoryPressure: return "memorychip"
case .lowStorage: return "internaldrive"
case .lowBattery: return "battery.25"
}
}
private func color(for severity: AlertSeverity) -> Color {
switch severity {
case .critical: return .red
case .warning: return .orange
}
}
private func outcomeIcon(_ outcome: AlertEventOutcome) -> String {
switch outcome {
case .triggered: return "bell.fill"
case .resolved: return "checkmark.circle.fill"
case .suppressedCooldown: return "bell.slash"
}
}
private func outcomeColor(_ outcome: AlertEventOutcome) -> Color {
switch outcome {
case .triggered: return .red
case .resolved: return .green
case .suppressedCooldown: return .secondary
}
}
}