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 <noreply@anthropic.com>
This commit is contained in:
@@ -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 = "<group>"; };
|
||||
0D5B918C9462144E9EE7880A /* Alert.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Alert.swift; sourceTree = "<group>"; };
|
||||
0E786723FC46A64EFF5D3CE4 /* MetricsStoreTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MetricsStoreTests.swift; sourceTree = "<group>"; };
|
||||
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 = "<group>"; };
|
||||
126A382E2755C5061E6D29F5 /* HealthView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HealthView.swift; sourceTree = "<group>"; };
|
||||
162FC58F6C44EC1DD7022A86 /* DiskMonitor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DiskMonitor.swift; sourceTree = "<group>"; };
|
||||
19ED582C412C63A0A2E2103E /* CPUMonitor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CPUMonitor.swift; sourceTree = "<group>"; };
|
||||
279B972C08A878053821285D /* SystemMonitor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SystemMonitor.swift; sourceTree = "<group>"; };
|
||||
@@ -95,7 +102,10 @@
|
||||
BBFADE83965AA863B775A910 /* NetworkChartView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NetworkChartView.swift; sourceTree = "<group>"; };
|
||||
BD3EDCDEDF4A0CE1921A3B25 /* MemoryInfo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MemoryInfo.swift; sourceTree = "<group>"; };
|
||||
BE3BB62AE0DC43777AD00658 /* GPUMonitor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GPUMonitor.swift; sourceTree = "<group>"; };
|
||||
C2C96471F109FF1C20ABF59E /* AlertCenter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AlertCenter.swift; sourceTree = "<group>"; };
|
||||
C4F1ACD0492CAB741595DEAA /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = "<group>"; };
|
||||
C70E54E350F7C8C22D342594 /* HealthModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HealthModel.swift; sourceTree = "<group>"; };
|
||||
E539A84E57291B69D4EE0314 /* HealthAlertTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HealthAlertTests.swift; sourceTree = "<group>"; };
|
||||
F481045B4FC6017F50849FF1 /* GPUInfo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GPUInfo.swift; sourceTree = "<group>"; };
|
||||
F80A817DCDA254349DEA6F06 /* PerformanceView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PerformanceView.swift; sourceTree = "<group>"; };
|
||||
/* 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 = "<group>";
|
||||
};
|
||||
CD1E8360C279FD4EFAD85DF6 /* Health */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
126A382E2755C5061E6D29F5 /* HealthView.swift */,
|
||||
);
|
||||
path = Health;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
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 */,
|
||||
|
||||
68
TopManager/Models/Alert.swift
Normal file
68
TopManager/Models/Alert.swift
Normal file
@@ -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)
|
||||
}
|
||||
}
|
||||
82
TopManager/Models/HealthModel.swift
Normal file
82
TopManager/Models/HealthModel.swift
Normal file
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
132
TopManager/Services/AlertCenter.swift
Normal file
132
TopManager/Services/AlertCenter.swift
Normal file
@@ -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<AlertKind> = []
|
||||
|
||||
@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()
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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))
|
||||
|
||||
135
TopManager/Views/Health/HealthView.swift
Normal file
135
TopManager/Views/Health/HealthView.swift
Normal file
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
110
TopManagerTests/HealthAlertTests.swift
Normal file
110
TopManagerTests/HealthAlertTests.swift
Normal file
@@ -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))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user