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:
hariel1985
2026-07-02 20:20:24 +02:00
szülő 9b92fb715a
commit bc494af363
10 fájl változott, egészen pontosan 608 új sor hozzáadva és 1 régi sor törölve

Fájl megtekintése

@@ -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 */,

Fájl megtekintése

@@ -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)
}
}

Fájl megtekintése

@@ -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"
}
}
}

Fájl megtekintése

@@ -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()
}
}

Fájl megtekintése

@@ -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

Fájl megtekintése

@@ -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))

Fájl megtekintése

@@ -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
}
}
}

Fájl megtekintése

@@ -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
}
}
}

Fájl megtekintése

@@ -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)

Fájl megtekintése

@@ -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))
}
}