From bc494af3639edabe4daa568b88eff8db7debb4a1 Mon Sep 17 00:00:00 2001 From: hariel1985 Date: Thu, 2 Jul 2026 20:20:24 +0200 Subject: [PATCH] Increment 4: alerts engine, notifications & System Health score - HealthModel (pure): 0-100 score + plain-language diagnosis + rating - SystemAlert/AlertKind/AlertThresholds + pure AlertEvaluator (sustained-breach) - AlertCenter (@MainActor): per-cycle health + alert evaluation, de-dup by kind, inbox (capped), native UserNotifications on transition into a condition - Covers: sustained high CPU, critical memory pressure, disk full, thermal, runaway process, low battery - Wire evaluate() into SystemMonitor; request notif auth on launch - HealthCardView (score ring + diagnosis) + AlertsInboxView in Power & Storage; health score + alert badge in menu bar - Renamed model Alert->SystemAlert to avoid SwiftUI.Alert shadowing - 10 new tests incl. AlertCenter de-dup integration (52 total green) - Visual smoke: Energy + Disk I/O columns render with live data under load Co-Authored-By: Claude Opus 4.8 --- TopManager.xcodeproj/project.pbxproj | 28 ++++ TopManager/Models/Alert.swift | 68 +++++++++ TopManager/Models/HealthModel.swift | 82 +++++++++++ TopManager/Services/AlertCenter.swift | 132 +++++++++++++++++ TopManager/Services/SystemMonitor.swift | 20 +++ TopManager/TopManagerApp.swift | 3 + TopManager/Views/Health/HealthView.swift | 135 ++++++++++++++++++ TopManager/Views/MenuBarView.swift | 28 +++- .../Views/PowerStorage/PowerStorageView.swift | 3 + TopManagerTests/HealthAlertTests.swift | 110 ++++++++++++++ 10 files changed, 608 insertions(+), 1 deletion(-) create mode 100644 TopManager/Models/Alert.swift create mode 100644 TopManager/Models/HealthModel.swift create mode 100644 TopManager/Services/AlertCenter.swift create mode 100644 TopManager/Views/Health/HealthView.swift create mode 100644 TopManagerTests/HealthAlertTests.swift diff --git a/TopManager.xcodeproj/project.pbxproj b/TopManager.xcodeproj/project.pbxproj index 24dbc64..ac425d7 100644 --- a/TopManager.xcodeproj/project.pbxproj +++ b/TopManager.xcodeproj/project.pbxproj @@ -19,9 +19,13 @@ 341C12CED58388D32ED6C5BB /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4F1ACD0492CAB741595DEAA /* ContentView.swift */; }; 35852165B2DA76AB7F073723 /* MenuBarView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9D71CC63FC2FEF52A069372E /* MenuBarView.swift */; }; 4427B8406B25B8FEDF27CDBA /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 334573CEB47D60570A40B55D /* Assets.xcassets */; }; + 489E29F78A4093D3548673E6 /* HealthAlertTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = E539A84E57291B69D4EE0314 /* HealthAlertTests.swift */; }; 49D85FF611927294159C89D1 /* FormattingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 301352EB5B362C732CE1A246 /* FormattingTests.swift */; }; 501C13D57386C179C0B3B14B /* PowerStorageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6038CCDF3BDF0F8BC93C9D4 /* PowerStorageView.swift */; }; + 67F66E7EC7C01A6309DC060E /* AlertCenter.swift in Sources */ = {isa = PBXBuildFile; fileRef = C2C96471F109FF1C20ABF59E /* AlertCenter.swift */; }; + 6A0810422D2EC8F9F5D191C4 /* HealthView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 126A382E2755C5061E6D29F5 /* HealthView.swift */; }; 744F52D036D1F3E081923382 /* ProcessInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4CF69D4888FDBB3669619A1A /* ProcessInfo.swift */; }; + 79E837AE662021C521F7E74F /* HealthModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = C70E54E350F7C8C22D342594 /* HealthModel.swift */; }; 7A1144E43159D96564C13164 /* MemoryInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD3EDCDEDF4A0CE1921A3B25 /* MemoryInfo.swift */; }; 833976FD8DC4CCC45078CF9A /* DiskMonitor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 162FC58F6C44EC1DD7022A86 /* DiskMonitor.swift */; }; 8F9ED99AB6D8D90AE79B5179 /* DiskUsageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 95B3F62455B01B2E9C0D76A9 /* DiskUsageView.swift */; }; @@ -42,6 +46,7 @@ E40A4E80ED4092A3D3A9C219 /* MemoryDonutView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8F402CE13EF67DE550786B79 /* MemoryDonutView.swift */; }; E4AF7F7CA1E1FE8638CA5A77 /* NetworkInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3010F605712E09175D9D8878 /* NetworkInfo.swift */; }; EB0D84848F42134927F9C9B1 /* NetworkMonitor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 347E47F825E496F40FD8ED11 /* NetworkMonitor.swift */; }; + EB7EAB13C0DC7865BB1A99D5 /* Alert.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0D5B918C9462144E9EE7880A /* Alert.swift */; }; F5722D2B18EF7ABF43847EE4 /* MemoryMonitor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 27FDD19B8DBB7D955A5ECB5B /* MemoryMonitor.swift */; }; FA252F9182ADDA26AD1E54DE /* CPUChartView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 94D5A13CC5B9CF1B90CC2214 /* CPUChartView.swift */; }; /* End PBXBuildFile section */ @@ -58,9 +63,11 @@ /* Begin PBXFileReference section */ 05CC33DC4E93F6ED2E52F4E1 /* MetricsStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MetricsStore.swift; sourceTree = ""; }; + 0D5B918C9462144E9EE7880A /* Alert.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Alert.swift; sourceTree = ""; }; 0E786723FC46A64EFF5D3CE4 /* MetricsStoreTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MetricsStoreTests.swift; sourceTree = ""; }; 0F5A69E0EE7FDD44157911D7 /* TopManagerTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = TopManagerTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; 110A314A63AE355A9E04990D /* ProcessDetailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProcessDetailView.swift; sourceTree = ""; }; + 126A382E2755C5061E6D29F5 /* HealthView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HealthView.swift; sourceTree = ""; }; 162FC58F6C44EC1DD7022A86 /* DiskMonitor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DiskMonitor.swift; sourceTree = ""; }; 19ED582C412C63A0A2E2103E /* CPUMonitor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CPUMonitor.swift; sourceTree = ""; }; 279B972C08A878053821285D /* SystemMonitor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SystemMonitor.swift; sourceTree = ""; }; @@ -95,7 +102,10 @@ BBFADE83965AA863B775A910 /* NetworkChartView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NetworkChartView.swift; sourceTree = ""; }; BD3EDCDEDF4A0CE1921A3B25 /* MemoryInfo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MemoryInfo.swift; sourceTree = ""; }; BE3BB62AE0DC43777AD00658 /* GPUMonitor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GPUMonitor.swift; sourceTree = ""; }; + C2C96471F109FF1C20ABF59E /* AlertCenter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AlertCenter.swift; sourceTree = ""; }; C4F1ACD0492CAB741595DEAA /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = ""; }; + C70E54E350F7C8C22D342594 /* HealthModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HealthModel.swift; sourceTree = ""; }; + E539A84E57291B69D4EE0314 /* HealthAlertTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HealthAlertTests.swift; sourceTree = ""; }; F481045B4FC6017F50849FF1 /* GPUInfo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GPUInfo.swift; sourceTree = ""; }; F80A817DCDA254349DEA6F06 /* PerformanceView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PerformanceView.swift; sourceTree = ""; }; /* End PBXFileReference section */ @@ -104,6 +114,7 @@ 06D63CC4D5468CEC1E31137A /* Services */ = { isa = PBXGroup; children = ( + C2C96471F109FF1C20ABF59E /* AlertCenter.swift */, 19ED582C412C63A0A2E2103E /* CPUMonitor.swift */, 162FC58F6C44EC1DD7022A86 /* DiskMonitor.swift */, BE3BB62AE0DC43777AD00658 /* GPUMonitor.swift */, @@ -129,9 +140,11 @@ 32ADF00BA31F63CAE52B2D0A /* Models */ = { isa = PBXGroup; children = ( + 0D5B918C9462144E9EE7880A /* Alert.swift */, 44281079654C7E1C9E16806C /* CPUInfo.swift */, 352CA1BE2A3B43643A9DBD72 /* DiskInfo.swift */, F481045B4FC6017F50849FF1 /* GPUInfo.swift */, + C70E54E350F7C8C22D342594 /* HealthModel.swift */, BD3EDCDEDF4A0CE1921A3B25 /* MemoryInfo.swift */, 3010F605712E09175D9D8878 /* NetworkInfo.swift */, 51C512B13DC0C1024CF39A5E /* PowerInfo.swift */, @@ -199,6 +212,7 @@ C4F1ACD0492CAB741595DEAA /* ContentView.swift */, 9D71CC63FC2FEF52A069372E /* MenuBarView.swift */, 6901D0C7B3DA57547BCFAAFC /* Apps */, + CD1E8360C279FD4EFAD85DF6 /* Health */, 4935484C55FD4C912A30D623 /* Performance */, CC52DA4C64987575E4D96A00 /* PowerStorage */, F2616FC0A232C728E792C2E8 /* Processes */, @@ -215,11 +229,20 @@ path = PowerStorage; sourceTree = ""; }; + CD1E8360C279FD4EFAD85DF6 /* Health */ = { + isa = PBXGroup; + children = ( + 126A382E2755C5061E6D29F5 /* HealthView.swift */, + ); + path = Health; + sourceTree = ""; + }; D078BA97D4F498E5282AC5E0 /* TopManagerTests */ = { isa = PBXGroup; children = ( 70CCC4ED4F2BDC35ABD4F4F6 /* BatteryMathTests.swift */, 301352EB5B362C732CE1A246 /* FormattingTests.swift */, + E539A84E57291B69D4EE0314 /* HealthAlertTests.swift */, 0E786723FC46A64EFF5D3CE4 /* MetricsStoreTests.swift */, 332290DABD5326C87258723C /* ProcessDataTests.swift */, ); @@ -341,6 +364,7 @@ files = ( 2DE14B65EA1D4E9B4D9DA5A6 /* BatteryMathTests.swift in Sources */, 49D85FF611927294159C89D1 /* FormattingTests.swift in Sources */, + 489E29F78A4093D3548673E6 /* HealthAlertTests.swift in Sources */, 24F63686869A69C8B0FCD7FA /* MetricsStoreTests.swift in Sources */, 2B74C177F49B4358D9642712 /* ProcessDataTests.swift in Sources */, ); @@ -350,6 +374,8 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + EB7EAB13C0DC7865BB1A99D5 /* Alert.swift in Sources */, + 67F66E7EC7C01A6309DC060E /* AlertCenter.swift in Sources */, A7EC9FB55D5088D669491C11 /* AppsView.swift in Sources */, DE50ECC69FCE60B10364802C /* ByteFormatters.swift in Sources */, FA252F9182ADDA26AD1E54DE /* CPUChartView.swift in Sources */, @@ -361,6 +387,8 @@ 8F9ED99AB6D8D90AE79B5179 /* DiskUsageView.swift in Sources */, 1F25A84B560709AE71C27D23 /* GPUInfo.swift in Sources */, E379C349DC9617D6C505B74B /* GPUMonitor.swift in Sources */, + 79E837AE662021C521F7E74F /* HealthModel.swift in Sources */, + 6A0810422D2EC8F9F5D191C4 /* HealthView.swift in Sources */, E40A4E80ED4092A3D3A9C219 /* MemoryDonutView.swift in Sources */, 7A1144E43159D96564C13164 /* MemoryInfo.swift in Sources */, 266B888ADE2FDDBCEC8D28A5 /* MemoryLineChart.swift in Sources */, diff --git a/TopManager/Models/Alert.swift b/TopManager/Models/Alert.swift new file mode 100644 index 0000000..17a431d --- /dev/null +++ b/TopManager/Models/Alert.swift @@ -0,0 +1,68 @@ +import Foundation + +enum AlertSeverity: Int, Comparable { + case info = 0, warning, critical + static func < (lhs: AlertSeverity, rhs: AlertSeverity) -> Bool { lhs.rawValue < rhs.rawValue } + + var label: String { + switch self { + case .info: return "Info" + case .warning: return "Warning" + case .critical: return "Critical" + } + } + var symbol: String { + switch self { + case .info: return "info.circle.fill" + case .warning: return "exclamationmark.triangle.fill" + case .critical: return "exclamationmark.octagon.fill" + } + } + var colorName: String { + switch self { + case .info: return "blue" + case .warning: return "orange" + case .critical: return "red" + } + } +} + +/// Distinct alert categories. The kind is the de-dup key: at most one active +/// alert per kind at a time, so we never storm the user with duplicates. +enum AlertKind: String { + case cpuHigh, memoryPressure, diskFull, thermal, runawayProcess, lowBattery +} + +/// Named `SystemAlert` (not `Alert`) to avoid shadowing SwiftUI's `Alert` type +/// inside view files. +struct SystemAlert: Identifiable { + let id = UUID() + let kind: AlertKind + let title: String + let message: String + let severity: AlertSeverity + let timestamp: Date +} + +/// User-tunable thresholds (wired to Settings in a later increment; sensible +/// defaults here). +struct AlertThresholds: Equatable { + var cpuPercent: Double = 90 + var cpuSustainedCycles: Int = 3 + var memoryPressureCritical = true + var diskUsedFraction: Double = 0.95 + var thermalSerious = true + var processCpuPercent: Double = 190 // per-core (~1.9 cores) sustained + var processSustainedCycles: Int = 3 + var lowBatteryPercent = 15 +} + +/// Pure helper for "sustained" conditions: a breach must persist for N +/// consecutive samples before it fires, and it clears as soon as it recovers. +/// Returned `count` is fed back in on the next call. +enum AlertEvaluator { + static func sustained(breached: Bool, previousCount: Int, requiredCycles: Int) -> (firing: Bool, count: Int) { + let count = breached ? previousCount + 1 : 0 + return (count >= requiredCycles, count) + } +} diff --git a/TopManager/Models/HealthModel.swift b/TopManager/Models/HealthModel.swift new file mode 100644 index 0000000..6bffc87 --- /dev/null +++ b/TopManager/Models/HealthModel.swift @@ -0,0 +1,82 @@ +import Foundation + +/// Architecture-neutral thermal level so the pure health model doesn't depend on +/// Foundation's `ProcessInfo.ThermalState` (keeps it trivially unit-testable). +enum ThermalLevel { + case nominal, fair, serious, critical +} + +/// Everything the health score needs, as plain values. +struct HealthInput { + let cpuUsage: Double // global CPU % + let memoryPressure: MemoryPressure + let swapUsedBytes: UInt64 + let thermal: ThermalLevel + let diskUsedFraction: Double // 0…1 for the fullest volume +} + +/// Computes a 0–100 "system health" score and a plain-language diagnosis. +/// This is what turns raw numbers into "is my Mac OK, and if not, why?". +enum HealthModel { + static func score(_ input: HealthInput) -> Int { + var score = 100.0 + + // Sustained high CPU: lose up to ~20 points as usage climbs 60→100%. + if input.cpuUsage > 60 { + score -= min(20, (input.cpuUsage - 60) * 0.5) + } + + switch input.memoryPressure { + case .warning: score -= 15 + case .critical: score -= 30 + default: break + } + + if input.swapUsedBytes > 2_000_000_000 { score -= 10 } + else if input.swapUsedBytes > 500_000_000 { score -= 5 } + + switch input.thermal { + case .fair: score -= 5 + case .serious: score -= 20 + case .critical: score -= 35 + case .nominal: break + } + + if input.diskUsedFraction > 0.95 { score -= 15 } + else if input.diskUsedFraction > 0.90 { score -= 8 } + + return max(0, min(100, Int(score.rounded()))) + } + + static func diagnosis(_ input: HealthInput) -> [String] { + var issues: [String] = [] + if input.cpuUsage > 85 { + issues.append("CPU is heavily loaded (\(Int(input.cpuUsage))%)") + } + switch input.memoryPressure { + case .critical: issues.append("Memory pressure is critical — the system is low on RAM") + case .warning: issues.append("Memory pressure is elevated") + default: break + } + if input.swapUsedBytes > 2_000_000_000 { + issues.append("High swap usage — closing apps may help") + } + switch input.thermal { + case .serious, .critical: issues.append("System is running hot and may be throttling") + default: break + } + if input.diskUsedFraction > 0.90 { + issues.append("Startup disk is nearly full (\(Int(input.diskUsedFraction * 100))%)") + } + return issues + } + + static func rating(_ score: Int) -> String { + switch score { + case 85...: return "Excellent" + case 70..<85: return "Good" + case 50..<70: return "Fair" + default: return "Poor" + } + } +} diff --git a/TopManager/Services/AlertCenter.swift b/TopManager/Services/AlertCenter.swift new file mode 100644 index 0000000..f672547 --- /dev/null +++ b/TopManager/Services/AlertCenter.swift @@ -0,0 +1,132 @@ +import Foundation +import Combine +import UserNotifications + +/// The proactive brain: each refresh it computes the system health score and +/// evaluates alert conditions, firing a native notification + inbox entry on the +/// transition into each condition (never duplicating a still-active alert). +@MainActor +final class AlertCenter: ObservableObject { + static let shared = AlertCenter() + + @Published var healthScore: Int = 100 + @Published var diagnosis: [String] = [] + @Published var alerts: [SystemAlert] = [] // inbox, newest first + @Published private(set) var activeKinds: Set = [] + + @Published var thresholds = AlertThresholds() + @Published var notificationsEnabled = true + + private let inboxLimit = 50 + private var cpuCount = 0 + private var procCount = 0 + private var notificationsAuthorized = false + + private init() {} + + var activeAlertCount: Int { activeKinds.count } + + func requestNotificationAuthorization() { + UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .sound]) { granted, _ in + Task { @MainActor in self.notificationsAuthorized = granted } + } + } + + func evaluate(cpuUsage: Double, + memory: MemoryInfo?, + disk: DiskInfo?, + thermal: ThermalLevel, + topProcess: ProcessItem?, + power: PowerInfo?) { + let diskFraction = disk?.volumes.map { $0.usagePercentage / 100 }.max() ?? 0 + + // Health score + diagnosis + let input = HealthInput( + cpuUsage: cpuUsage, + memoryPressure: memory?.memoryPressure ?? .unknown, + swapUsedBytes: memory?.swapUsed ?? 0, + thermal: thermal, + diskUsedFraction: diskFraction + ) + healthScore = HealthModel.score(input) + diagnosis = HealthModel.diagnosis(input) + + // CPU (sustained) + let cpuEval = AlertEvaluator.sustained(breached: cpuUsage >= thresholds.cpuPercent, + previousCount: cpuCount, + requiredCycles: thresholds.cpuSustainedCycles) + cpuCount = cpuEval.count + setActive(.cpuHigh, active: cpuEval.firing, + title: "High CPU usage", + message: String(format: "CPU has stayed above %.0f%% (now %.0f%%).", thresholds.cpuPercent, cpuUsage), + severity: .warning) + + // Memory pressure + let memCritical = thresholds.memoryPressureCritical && memory?.memoryPressure == .critical + setActive(.memoryPressure, active: memCritical, + title: "Critical memory pressure", + message: "The system is very low on memory. Consider closing apps.", + severity: .critical) + + // Disk + let diskFull = diskFraction >= thresholds.diskUsedFraction + setActive(.diskFull, active: diskFull, + title: "Disk almost full", + message: String(format: "A volume is %.0f%% full.", diskFraction * 100), + severity: .warning) + + // Thermal + let thermalBad = thresholds.thermalSerious && (thermal == .serious || thermal == .critical) + setActive(.thermal, active: thermalBad, + title: "System is running hot", + message: "Thermal state is elevated; performance may be throttled.", + severity: thermal == .critical ? .critical : .warning) + + // Runaway process (sustained) + let runaway = (topProcess?.cpuUsage ?? 0) >= thresholds.processCpuPercent + let procEval = AlertEvaluator.sustained(breached: runaway, + previousCount: procCount, + requiredCycles: thresholds.processSustainedCycles) + procCount = procEval.count + setActive(.runawayProcess, active: procEval.firing, + title: "Runaway process", + message: topProcess.map { "\($0.name) (PID \($0.pid)) is using \(Int($0.cpuUsage))% CPU." } ?? "", + severity: .warning) + + // Low battery + let lowBattery = (power?.hasBattery ?? false) + && !(power?.isPluggedIn ?? true) + && (power?.currentCharge ?? 100) <= thresholds.lowBatteryPercent + setActive(.lowBattery, active: lowBattery, + title: "Low battery", + message: "Battery is at \(power?.currentCharge ?? 0)% and not charging.", + severity: .warning) + } + + private func setActive(_ kind: AlertKind, active: Bool, title: String, message: String, severity: AlertSeverity) { + if active { + guard !activeKinds.contains(kind) else { return } // de-dup: already firing + activeKinds.insert(kind) + let alert = SystemAlert(kind: kind, title: title, message: message, severity: severity, timestamp: Date()) + alerts.insert(alert, at: 0) + if alerts.count > inboxLimit { alerts.removeLast(alerts.count - inboxLimit) } + postNotification(alert) + } else { + activeKinds.remove(kind) + } + } + + private func postNotification(_ alert: SystemAlert) { + guard notificationsEnabled, notificationsAuthorized else { return } + let content = UNMutableNotificationContent() + content.title = alert.title + content.body = alert.message + content.sound = alert.severity == .critical ? .defaultCritical : .default + let request = UNNotificationRequest(identifier: alert.id.uuidString, content: content, trigger: nil) + UNUserNotificationCenter.current().add(request) + } + + func clearInbox() { + alerts.removeAll() + } +} diff --git a/TopManager/Services/SystemMonitor.swift b/TopManager/Services/SystemMonitor.swift index 0bd7f3b..2d0755d 100644 --- a/TopManager/Services/SystemMonitor.swift +++ b/TopManager/Services/SystemMonitor.swift @@ -200,6 +200,26 @@ final class SystemMonitor: ObservableObject { netUp: networkInfo?.totalUploadRate ?? 0 )) } + + // Proactive health + alert evaluation (runs every cycle). + AlertCenter.shared.evaluate( + cpuUsage: cpuInfo?.globalUsage ?? 0, + memory: memoryInfo, + disk: diskInfo, + thermal: currentThermalLevel(), + topProcess: self.processes.max { $0.cpuUsage < $1.cpuUsage }, + power: powerInfo + ) + } + + private func currentThermalLevel() -> ThermalLevel { + switch ProcessInfo.processInfo.thermalState { + case .nominal: return .nominal + case .fair: return .fair + case .serious: return .serious + case .critical: return .critical + @unknown default: return .nominal + } } // Process control methods with PID validation diff --git a/TopManager/TopManagerApp.swift b/TopManager/TopManagerApp.swift index d976e5d..c7b5e71 100644 --- a/TopManager/TopManagerApp.swift +++ b/TopManager/TopManagerApp.swift @@ -18,8 +18,10 @@ struct TopManagerApp: App { WindowGroup { ContentView() .environmentObject(monitor) + .environmentObject(AlertCenter.shared) .onAppear { monitor.startMonitoring() + AlertCenter.shared.requestNotificationAuthorization() } } .windowStyle(.automatic) @@ -37,6 +39,7 @@ struct TopManagerApp: App { MenuBarExtra { MenuBarView() .environmentObject(monitor) + .environmentObject(AlertCenter.shared) } label: { if let cpu = monitor.cpuInfo { Text(String(format: "%.0f%%", cpu.globalUsage)) diff --git a/TopManager/Views/Health/HealthView.swift b/TopManager/Views/Health/HealthView.swift new file mode 100644 index 0000000..aa86781 --- /dev/null +++ b/TopManager/Views/Health/HealthView.swift @@ -0,0 +1,135 @@ +import SwiftUI + +/// System Health summary card: a 0–100 score, rating, and plain-language +/// diagnosis of what (if anything) is wrong right now. +struct HealthCardView: View { + @EnvironmentObject var alertCenter: AlertCenter + + var body: some View { + GroupBox { + HStack(alignment: .top, spacing: 20) { + // Score ring + ZStack { + Circle() + .stroke(Color.secondary.opacity(0.2), lineWidth: 10) + Circle() + .trim(from: 0, to: CGFloat(alertCenter.healthScore) / 100) + .stroke(scoreColor, style: StrokeStyle(lineWidth: 10, lineCap: .round)) + .rotationEffect(.degrees(-90)) + VStack(spacing: 0) { + Text("\(alertCenter.healthScore)") + .font(.system(size: 30, weight: .bold, design: .rounded)) + .monospacedDigit() + Text(HealthModel.rating(alertCenter.healthScore)) + .font(.caption) + .foregroundColor(.secondary) + } + } + .frame(width: 96, height: 96) + + VStack(alignment: .leading, spacing: 8) { + HStack { + Label("System Health", systemImage: "heart.text.square") + .font(.headline) + Spacer() + if alertCenter.activeAlertCount > 0 { + Label("\(alertCenter.activeAlertCount) active", systemImage: "bell.badge.fill") + .font(.caption) + .foregroundColor(.orange) + } + } + + if alertCenter.diagnosis.isEmpty { + Label("Everything looks healthy.", systemImage: "checkmark.circle.fill") + .font(.callout) + .foregroundColor(.green) + } else { + ForEach(alertCenter.diagnosis, id: \.self) { issue in + Label(issue, systemImage: "exclamationmark.triangle.fill") + .font(.callout) + .foregroundColor(.secondary) + } + } + Spacer(minLength: 0) + } + } + .padding(.vertical, 8) + .frame(maxWidth: .infinity, alignment: .leading) + } + } + + private var scoreColor: Color { + switch alertCenter.healthScore { + case 85...: return .green + case 70..<85: return .mint + case 50..<70: return .orange + default: return .red + } + } +} + +/// Inbox of recent alerts fired by the AlertCenter. +struct AlertsInboxView: View { + @EnvironmentObject var alertCenter: AlertCenter + + var body: some View { + GroupBox { + VStack(alignment: .leading, spacing: 10) { + HStack { + Label("Recent Alerts", systemImage: "bell") + .font(.headline) + Spacer() + if !alertCenter.alerts.isEmpty { + Button("Clear") { alertCenter.clearInbox() } + .buttonStyle(.borderless) + .font(.caption) + } + } + + if alertCenter.alerts.isEmpty { + Text("No alerts. TopManager will notify you if something needs attention.") + .font(.callout) + .foregroundColor(.secondary) + } else { + ForEach(alertCenter.alerts.prefix(8)) { alert in + AlertRowView(alert: alert) + } + } + } + .padding(.vertical, 8) + } + } +} + +struct AlertRowView: View { + let alert: SystemAlert + + var body: some View { + HStack(alignment: .top, spacing: 8) { + Image(systemName: alert.severity.symbol) + .foregroundColor(severityColor) + VStack(alignment: .leading, spacing: 2) { + HStack { + Text(alert.title).fontWeight(.medium) + Spacer() + Text(alert.timestamp, style: .time) + .font(.caption) + .foregroundColor(.secondary) + } + Text(alert.message) + .font(.caption) + .foregroundColor(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + } + Divider() + } + + private var severityColor: Color { + switch alert.severity { + case .info: return .blue + case .warning: return .orange + case .critical: return .red + } + } +} diff --git a/TopManager/Views/MenuBarView.swift b/TopManager/Views/MenuBarView.swift index 9ba7948..222f4f0 100644 --- a/TopManager/Views/MenuBarView.swift +++ b/TopManager/Views/MenuBarView.swift @@ -2,9 +2,26 @@ import SwiftUI struct MenuBarView: View { @EnvironmentObject var monitor: SystemMonitor + @EnvironmentObject var alertCenter: AlertCenter var body: some View { VStack(alignment: .leading, spacing: 8) { + HStack { + Image(systemName: "heart.text.square") + .frame(width: 16) + .foregroundColor(healthColor) + Text("Health:") + Spacer() + Text("\(alertCenter.healthScore)/100") + .monospacedDigit() + .foregroundColor(healthColor) + if alertCenter.activeAlertCount > 0 { + Image(systemName: "bell.badge.fill") + .foregroundColor(.orange) + } + } + Divider() + if let cpu = monitor.cpuInfo { HStack { Image(systemName: "cpu") @@ -88,7 +105,16 @@ struct MenuBarView: View { .keyboardShortcut("q") } .padding(12) - .frame(width: 220) + .frame(width: 240) + } + + private var healthColor: Color { + switch alertCenter.healthScore { + case 85...: return .green + case 70..<85: return .mint + case 50..<70: return .orange + default: return .red + } } } diff --git a/TopManager/Views/PowerStorage/PowerStorageView.swift b/TopManager/Views/PowerStorage/PowerStorageView.swift index 825e347..eea5a0b 100644 --- a/TopManager/Views/PowerStorage/PowerStorageView.swift +++ b/TopManager/Views/PowerStorage/PowerStorageView.swift @@ -6,6 +6,9 @@ struct PowerStorageView: View { var body: some View { ScrollView { VStack(spacing: 20) { + // System Health score + diagnosis + HealthCardView() + // System Status and GPU side by side HStack(alignment: .top, spacing: 20) { // System Status (left column) diff --git a/TopManagerTests/HealthAlertTests.swift b/TopManagerTests/HealthAlertTests.swift new file mode 100644 index 0000000..9d4b2b4 --- /dev/null +++ b/TopManagerTests/HealthAlertTests.swift @@ -0,0 +1,110 @@ +import XCTest +@testable import TopManager + +final class HealthAlertTests: XCTestCase { + + private func healthy() -> HealthInput { + HealthInput(cpuUsage: 10, memoryPressure: .nominal, swapUsedBytes: 0, + thermal: .nominal, diskUsedFraction: 0.4) + } + + // MARK: - Health score + + func testHealthyScoreIsExcellent() { + let s = HealthModel.score(healthy()) + XCTAssertEqual(s, 100) + XCTAssertEqual(HealthModel.rating(s), "Excellent") + } + + func testHighCPULowersScore() { + var input = healthy() + input = HealthInput(cpuUsage: 100, memoryPressure: .nominal, swapUsedBytes: 0, + thermal: .nominal, diskUsedFraction: 0.4) + XCTAssertLessThan(HealthModel.score(input), HealthModel.score(healthy())) + } + + func testCriticalMemoryAndThermalCompound() { + let input = HealthInput(cpuUsage: 95, memoryPressure: .critical, swapUsedBytes: 3_000_000_000, + thermal: .critical, diskUsedFraction: 0.97) + let s = HealthModel.score(input) + XCTAssertLessThan(s, 50) + XCTAssertEqual(HealthModel.rating(s), "Poor") + XCTAssertGreaterThanOrEqual(s, 0) + } + + func testScoreClampedToRange() { + // Absolute worst case must not underflow below 0. + let worst = HealthInput(cpuUsage: 100, memoryPressure: .critical, swapUsedBytes: .max, + thermal: .critical, diskUsedFraction: 1.0) + let s = HealthModel.score(worst) + XCTAssertTrue((0...100).contains(s)) + } + + func testDiagnosisEmptyWhenHealthy() { + XCTAssertTrue(HealthModel.diagnosis(healthy()).isEmpty) + } + + func testDiagnosisReportsProblems() { + let input = HealthInput(cpuUsage: 95, memoryPressure: .critical, swapUsedBytes: 3_000_000_000, + thermal: .serious, diskUsedFraction: 0.93) + let d = HealthModel.diagnosis(input) + XCTAssertFalse(d.isEmpty) + XCTAssertTrue(d.contains { $0.localizedCaseInsensitiveContains("CPU") }) + XCTAssertTrue(d.contains { $0.localizedCaseInsensitiveContains("memory") }) + XCTAssertTrue(d.contains { $0.localizedCaseInsensitiveContains("disk") }) + } + + // MARK: - Alert sustained evaluator + + func testSustainedFiresOnlyAfterRequiredCycles() { + var count = 0 + var result = AlertEvaluator.sustained(breached: true, previousCount: count, requiredCycles: 3) + count = result.count + XCTAssertFalse(result.firing) // 1 + result = AlertEvaluator.sustained(breached: true, previousCount: count, requiredCycles: 3) + count = result.count + XCTAssertFalse(result.firing) // 2 + result = AlertEvaluator.sustained(breached: true, previousCount: count, requiredCycles: 3) + count = result.count + XCTAssertTrue(result.firing) // 3 → fires + } + + func testSustainedResetsWhenConditionClears() { + var count = 5 + let result = AlertEvaluator.sustained(breached: false, previousCount: count, requiredCycles: 3) + count = result.count + XCTAssertFalse(result.firing) + XCTAssertEqual(count, 0) + } + + func testSeverityOrdering() { + XCTAssertTrue(AlertSeverity.info < AlertSeverity.warning) + XCTAssertTrue(AlertSeverity.warning < AlertSeverity.critical) + } + + // MARK: - AlertCenter integration (de-dup + inbox) + + @MainActor + func testAlertCenterDedupesAndClearsActiveKinds() { + let center = AlertCenter.shared + center.clearInbox() + + let mem = MemoryInfo(totalMemory: 16, usedMemory: 15, freeMemory: 1, activeMemory: 8, + inactiveMemory: 2, wiredMemory: 3, compressedMemory: 2, cachedMemory: 0, + swapUsed: 0, swapTotal: 0, memoryPressure: .critical) + + let before = center.alerts.count + // Two consecutive critical-pressure evaluations → only ONE alert (de-dup). + center.evaluate(cpuUsage: 5, memory: mem, disk: nil, thermal: .nominal, topProcess: nil, power: nil) + center.evaluate(cpuUsage: 5, memory: mem, disk: nil, thermal: .nominal, topProcess: nil, power: nil) + XCTAssertEqual(center.alerts.count, before + 1, "critical memory pressure should fire exactly once") + XCTAssertTrue(center.activeKinds.contains(.memoryPressure)) + + // Pressure recovers → active kind clears, no new alert added. + let ok = MemoryInfo(totalMemory: 16, usedMemory: 4, freeMemory: 12, activeMemory: 2, + inactiveMemory: 1, wiredMemory: 1, compressedMemory: 0, cachedMemory: 0, + swapUsed: 0, swapTotal: 0, memoryPressure: .nominal) + center.evaluate(cpuUsage: 5, memory: ok, disk: nil, thermal: .nominal, topProcess: nil, power: nil) + XCTAssertFalse(center.activeKinds.contains(.memoryPressure)) + } +}