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>
164 lines
6.1 KiB
Swift
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
|
|
}
|
|
}
|
|
}
|