Read-only mounts (DMG images like /Volumes/Ghostty, update snapshots) with ~0% free produced false-positive low-storage alerts. StorageVolumeContext now carries isReadOnly and totalBytes; the rule skips read-only volumes and volumes under 1 GB where thresholds are meaningless. Regression tests cover both exclusions plus the normal trigger path. Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
132 lines
4.5 KiB
Swift
132 lines
4.5 KiB
Swift
import Foundation
|
|
|
|
// MARK: - Alert Models
|
|
|
|
/// Categories of threshold conditions evaluated by `AlertEngine`.
|
|
public enum AlertKind: String, Codable, CaseIterable, Sendable {
|
|
case cpuTemperature
|
|
case fanStall
|
|
case memoryPressure
|
|
case lowStorage
|
|
case lowBattery
|
|
}
|
|
|
|
/// Visual and audible emphasis level for a raised alert.
|
|
public enum AlertSeverity: String, Codable, Sendable {
|
|
case warning
|
|
case critical
|
|
}
|
|
|
|
/// A single breached threshold condition.
|
|
public struct SystemAlert: Identifiable, Equatable, Sendable {
|
|
/// Stable identifier of the form `"<kind>"` or `"<kind>:<subject>"`
|
|
/// (e.g. `"lowStorage:/"`, `"fanStall:0"`) used for cooldown bookkeeping.
|
|
public let id: String
|
|
public let kind: AlertKind
|
|
public let severity: AlertSeverity
|
|
public let title: String
|
|
public let message: String
|
|
public let timestamp: Date
|
|
|
|
public init(id: String, kind: AlertKind, severity: AlertSeverity, title: String, message: String, timestamp: Date) {
|
|
self.id = id
|
|
self.kind = kind
|
|
self.severity = severity
|
|
self.title = title
|
|
self.message = message
|
|
self.timestamp = timestamp
|
|
}
|
|
}
|
|
|
|
/// What happened to an alert condition during an evaluation pass.
|
|
public enum AlertEventOutcome: String, Codable, Sendable {
|
|
case triggered
|
|
case resolved
|
|
case suppressedCooldown
|
|
}
|
|
|
|
/// An entry in the in-memory alert history log.
|
|
public struct AlertEvent: Identifiable, Equatable, Sendable {
|
|
public let id: UUID
|
|
public let timestamp: Date
|
|
public let alertID: String
|
|
public let kind: AlertKind
|
|
public let outcome: AlertEventOutcome
|
|
public let message: String
|
|
|
|
public init(timestamp: Date, alertID: String, kind: AlertKind, outcome: AlertEventOutcome, message: String) {
|
|
self.id = UUID()
|
|
self.timestamp = timestamp
|
|
self.alertID = alertID
|
|
self.kind = kind
|
|
self.outcome = outcome
|
|
self.message = message
|
|
}
|
|
}
|
|
|
|
/// Per-volume storage context consumed by the low-storage rule.
|
|
public struct StorageVolumeContext: Equatable, Sendable {
|
|
public let mountPoint: String
|
|
public let volumeName: String
|
|
public let freeBytes: UInt64
|
|
public let freePercent: Double
|
|
public let totalBytes: UInt64
|
|
/// Read-only mounts (DMG images, snapshots) are excluded from alerting.
|
|
public let isReadOnly: Bool
|
|
|
|
public init(mountPoint: String, volumeName: String, freeBytes: UInt64, freePercent: Double,
|
|
totalBytes: UInt64 = 0, isReadOnly: Bool = false) {
|
|
self.mountPoint = mountPoint
|
|
self.volumeName = volumeName
|
|
self.freeBytes = freeBytes
|
|
self.freePercent = freePercent
|
|
self.totalBytes = totalBytes
|
|
self.isReadOnly = isReadOnly
|
|
}
|
|
}
|
|
|
|
/// Flat, immutable snapshot of the metrics the alert rules inspect.
|
|
/// Assembled by `SystemTelemetryStore` after each decode pass so the engine
|
|
/// stays decoupled from provider dictionary schemas and testable in isolation.
|
|
public struct AlertEvaluationContext: Sendable {
|
|
public var peakCoreTemperature: Double = 0
|
|
public var packageTemperature: Double = 0
|
|
/// Fan index -> current RPM. Empty when no fan telemetry is available.
|
|
public var fanRPMs: [Int: Double] = [:]
|
|
public var memoryFreeBytes: UInt64 = 0
|
|
public var memoryPressureLevel: String = "Normal"
|
|
public var volumes: [StorageVolumeContext] = []
|
|
/// Charge level 0-100 reported by the power provider.
|
|
public var batteryLevel: Double = 100
|
|
public var hasBattery: Bool = false
|
|
public var onExternalPower: Bool = true
|
|
public var isCharging: Bool = false
|
|
public var timestamp: Date = Date()
|
|
|
|
public init(
|
|
peakCoreTemperature: Double = 0,
|
|
packageTemperature: Double = 0,
|
|
fanRPMs: [Int: Double] = [:],
|
|
memoryFreeBytes: UInt64 = 0,
|
|
memoryPressureLevel: String = "Normal",
|
|
volumes: [StorageVolumeContext] = [],
|
|
batteryLevel: Double = 100,
|
|
hasBattery: Bool = false,
|
|
onExternalPower: Bool = true,
|
|
isCharging: Bool = false,
|
|
timestamp: Date = Date()
|
|
) {
|
|
self.peakCoreTemperature = peakCoreTemperature
|
|
self.packageTemperature = packageTemperature
|
|
self.fanRPMs = fanRPMs
|
|
self.memoryFreeBytes = memoryFreeBytes
|
|
self.memoryPressureLevel = memoryPressureLevel
|
|
self.volumes = volumes
|
|
self.batteryLevel = batteryLevel
|
|
self.hasBattery = hasBattery
|
|
self.onExternalPower = onExternalPower
|
|
self.isCharging = isCharging
|
|
self.timestamp = timestamp
|
|
}
|
|
}
|