Compare commits

...

2 Commits

Author SHA1 Message Date
tracer 2b7747db5c chore: release 26.1.12 2026-04-24 19:17:57 +02:00
tracer d3af580f07 feat: add alert grace period controls 2026-04-24 19:16:20 +02:00
9 changed files with 202 additions and 27 deletions
+3
View File
@@ -5,6 +5,9 @@
- Added persisted metric history with SwiftData for CPU, memory, disk, and swap charts.
- Added `Hour`, `Day`, `Week`, and `Month` ranges to summary charts.
- Added CPU, memory, disk, and swap history widgets that expand across the available summary width.
- Added a configurable alert grace period in Preferences → Notifications.
- Delayed offline host and service alerts until the configured grace period has elapsed, while keeping recovery notifications immediate.
- Added reboot-required state to host status indicators by turning online markers yellow and showing an explanatory tooltip.
- Reworked `General` to remain the more traditional detailed information tab while `Summary` focuses on quick status and trends.
- Isolated metric history into an app-specific SwiftData store and recover cleanly from incompatible history stores.
- Fixed the summary CPU chart to use the summary payload's reported CPU percentage and allow values above `100%` with a dynamic Y axis.
+21 -2
View File
@@ -50,7 +50,12 @@ enum PingService {
}
private static func handlePingFailure(for hostname: String, notificationsEnabled: Bool) async {
if let notification = await stateStore.recordFailure(for: hostname, notificationsEnabled: notificationsEnabled) {
let alertGracePeriod = UserDefaults.standard.integer(forKey: "alertGracePeriod")
if let notification = await stateStore.recordFailure(
for: hostname,
notificationsEnabled: notificationsEnabled,
gracePeriod: TimeInterval(alertGracePeriod)
) {
sendNotification(title: notification.title, body: notification.body)
}
}
@@ -69,10 +74,12 @@ enum PingService {
private actor PingStateStore {
private var previousPingStates: [String: Bool] = [:]
private var suppressedUntil: [String: Date] = [:]
private var failureStartedAt: [String: Date] = [:]
func suppressChecks(for hostname: String, duration: TimeInterval) {
suppressedUntil[hostname] = Date().addingTimeInterval(duration)
previousPingStates[hostname] = false
failureStartedAt.removeValue(forKey: hostname)
}
func shouldSkipPing(for hostname: String) -> Bool {
@@ -87,6 +94,7 @@ private actor PingStateStore {
func recordSuccess(for hostname: String, notificationsEnabled: Bool) -> PingNotification? {
let wasPreviouslyDown = previousPingStates[hostname] == false
previousPingStates[hostname] = true
failureStartedAt.removeValue(forKey: hostname)
guard wasPreviouslyDown, notificationsEnabled else {
return nil
@@ -98,9 +106,20 @@ private actor PingStateStore {
)
}
func recordFailure(for hostname: String, notificationsEnabled: Bool) -> PingNotification? {
func recordFailure(for hostname: String, notificationsEnabled: Bool, gracePeriod: TimeInterval) -> PingNotification? {
let now = Date()
let startedAt = failureStartedAt[hostname] ?? now
if failureStartedAt[hostname] == nil {
failureStartedAt[hostname] = startedAt
}
guard now.timeIntervalSince(startedAt) >= gracePeriod else {
return nil
}
let wasPreviouslyUp = previousPingStates[hostname] != false
previousPingStates[hostname] = false
failureStartedAt.removeValue(forKey: hostname)
guard wasPreviouslyUp, notificationsEnabled else {
return nil
+11
View File
@@ -201,6 +201,17 @@ struct ServerInfo: Codable, Hashable, Equatable {
ServerInfo.version(apiVersion, isAtLeast: "2.14")
}
var rebootRequired: Bool {
operatingSystem?.updates?.rebootRequired ?? false
}
var statusTooltip: String {
if rebootRequired {
return "This host is online, but a reboot is required to complete pending updates."
}
return "This host is online."
}
var summaryCPUPercent: Double {
if let cpuUtilizationPercent {
return min(max(cpuUtilizationPercent, 0), 100)
+56 -7
View File
@@ -31,6 +31,7 @@ struct MainView: View {
@AppStorage("refreshInterval") private var refreshInterval: Int = 60
@AppStorage("enableStatusNotifications") private var enableStatusNotifications: Bool = true
@AppStorage("enableAlertNotifications") private var enableAlertNotifications: Bool = true
@AppStorage("alertGracePeriod") private var alertGracePeriod: Int = 30
@State private var refreshTimer: Timer.TimerPublisher?
@State private var refreshSubscription: AnyCancellable?
@State private var pingTimer: Timer?
@@ -40,6 +41,8 @@ struct MainView: View {
@State private var lastRefreshInterval: Int?
@State private var lastMetricPrune: Date?
@State private var previousServiceStates: [String: String] = [:]
@State private var serviceFailureStartedAt: [String: Date] = [:]
@State private var serviceOfflineAlerted: [String: Bool] = [:]
private let serverOrderKey = MainView.serverOrderKeyStatic
private let storedGroupsKey = MainView.storedGroupsKeyStatic
@Environment(\.modelContext) private var modelContext
@@ -242,9 +245,10 @@ struct MainView: View {
private func sidebarRow(for server: Server) -> some View {
HStack {
Image(systemName: "dot.circle.fill")
.foregroundColor(server.pingable ? .green : .red)
.foregroundColor(sidebarStatusColor(for: server))
Text(server.hostname)
}
.help(sidebarStatusTooltip(for: server))
.tag(server.id)
.contextMenu {
Button("Edit") {
@@ -259,6 +263,23 @@ struct MainView: View {
}
}
private func sidebarStatusColor(for server: Server) -> Color {
if !server.pingable {
return .red
}
if server.info?.rebootRequired == true {
return .yellow
}
return .green
}
private func sidebarStatusTooltip(for server: Server) -> String {
if !server.pingable {
return "This host is offline."
}
return server.info?.statusTooltip ?? "This host is online."
}
private func groupHeader(for group: ServerGroup) -> some View {
let activePlacement = groupDropIndicator?.groupID == group.id ? groupDropIndicator?.placement : nil
@@ -616,17 +637,45 @@ struct MainView: View {
for port in ports {
let key = "\(hostname)-\(port.id)"
let previousStatus = previousServiceStates[key]
let currentStatus = port.status
previousServiceStates[key] = currentStatus
let currentStatus = port.status.lowercased()
let now = Date()
if let previousStatus, previousStatus != currentStatus {
if currentStatus == "offline" && enableStatusNotifications {
sendServiceNotification(service: port.service, hostname: hostname, status: "offline")
} else if currentStatus == "online" && previousStatus == "offline" && enableStatusNotifications {
if currentStatus == "offline" {
serviceFailureStartedAt[key] = now
serviceOfflineAlerted[key] = false
} else if currentStatus == "online" {
let hadOfflineAlert = serviceOfflineAlerted[key] == true
serviceFailureStartedAt.removeValue(forKey: key)
serviceOfflineAlerted.removeValue(forKey: key)
if hadOfflineAlert && enableStatusNotifications {
sendServiceNotification(service: port.service, hostname: hostname, status: "online")
}
}
} else if previousStatus == nil {
if currentStatus == "offline" {
serviceFailureStartedAt[key] = now
serviceOfflineAlerted[key] = false
} else if currentStatus == "online" {
serviceFailureStartedAt.removeValue(forKey: key)
}
}
if currentStatus == "offline" {
let startedAt = serviceFailureStartedAt[key] ?? now
if serviceFailureStartedAt[key] == nil {
serviceFailureStartedAt[key] = startedAt
}
if serviceOfflineAlerted[key] != true,
enableAlertNotifications,
now.timeIntervalSince(startedAt) >= TimeInterval(alertGracePeriod) {
sendServiceNotification(service: port.service, hostname: hostname, status: "offline")
serviceOfflineAlerted[key] = true
}
}
previousServiceStates[key] = currentStatus
}
}
+72 -2
View File
@@ -28,15 +28,19 @@ struct PreferencesView: View {
@AppStorage("pingInterval") private var storedPingInterval: Int = 10
@AppStorage("refreshInterval") private var storedRefreshInterval: Int = 60
@AppStorage("showIntervalIndicator") private var showIntervalIndicator: Bool = true
@AppStorage("alertGracePeriod") private var storedAlertGracePeriod: Int = 30
@State private var pingIntervalSlider: Double = 10
@State private var refreshIntervalSlider: Double = 60
@State private var alertGracePeriodSlider: Double = 30
@State private var selection: Tab = .monitor
@State private var hoveredTab: Tab?
private let minimumInterval: Double = 10
private let maximumPingInterval: Double = 60
private let maximumRefreshInterval: Double = 600
private let minimumAlertGracePeriod: Double = 0
private let maximumAlertGracePeriod: Double = 300
var body: some View {
HStack(spacing: 0) {
@@ -53,6 +57,7 @@ struct PreferencesView: View {
.onAppear {
pingIntervalSlider = Double(storedPingInterval)
refreshIntervalSlider = Double(storedRefreshInterval)
alertGracePeriodSlider = Double(storedAlertGracePeriod)
}
.onChange(of: pingIntervalSlider) { _, newValue in
storedPingInterval = Int(newValue)
@@ -60,6 +65,9 @@ struct PreferencesView: View {
.onChange(of: refreshIntervalSlider) { _, newValue in
storedRefreshInterval = Int(newValue)
}
.onChange(of: alertGracePeriodSlider) { _, newValue in
storedAlertGracePeriod = Int(newValue)
}
}
private var sidebar: some View {
@@ -121,7 +129,12 @@ struct PreferencesView: View {
refreshChanged: handleRefreshSliderEditing(_:)
)
case .notifications:
NotificationsPreferencesView()
NotificationsPreferencesView(
alertGracePeriodSlider: $alertGracePeriodSlider,
minimumAlertGracePeriod: minimumAlertGracePeriod,
maximumAlertGracePeriod: maximumAlertGracePeriod,
alertGraceChanged: handleAlertGraceSliderEditing(_:)
)
case .alerts:
AlertsPreferencesView()
case .updates:
@@ -142,6 +155,13 @@ struct PreferencesView: View {
storedRefreshInterval = Int(refreshIntervalSlider)
}
}
private func handleAlertGraceSliderEditing(_ editing: Bool) {
if !editing {
storedAlertGracePeriod = Int(alertGracePeriodSlider)
}
}
private func backgroundColor(for tab: Tab) -> Color {
if selection == tab {
return sidebarSelectionColor
@@ -238,7 +258,7 @@ private struct MonitorPreferencesView: View {
in: range,
step: 5
) {
Text(title)
EmptyView()
} minimumValueLabel: {
Text("\(Int(range.lowerBound))s")
.font(.caption)
@@ -290,12 +310,62 @@ private struct UpdatesPreferencesView: View {
private struct NotificationsPreferencesView: View {
@AppStorage("enableStatusNotifications") private var enableStatusNotifications: Bool = true
@AppStorage("enableAlertNotifications") private var enableAlertNotifications: Bool = true
@Binding var alertGracePeriodSlider: Double
@State private var showAlertGraceHelp = false
let minimumAlertGracePeriod: Double
let maximumAlertGracePeriod: Double
let alertGraceChanged: (Bool) -> Void
var body: some View {
VStack(alignment: .leading, spacing: 18) {
Toggle("Status Notifications", isOn: $enableStatusNotifications)
Toggle("Alert Notifications", isOn: $enableAlertNotifications)
VStack(alignment: .leading, spacing: 8) {
HStack {
HStack(spacing: 6) {
Text("Alert grace period")
.font(.headline)
Button {
showAlertGraceHelp.toggle()
} label: {
Image(systemName: "questionmark.circle")
.foregroundColor(.secondary)
}
.buttonStyle(.plain)
.popover(isPresented: $showAlertGraceHelp, arrowEdge: .bottom) {
Text("Wait this long before raising an alert after a host or service first appears unavailable.")
.font(.callout)
.padding(12)
.frame(width: 280, alignment: .leading)
}
}
Spacer()
Text("\(Int(alertGracePeriodSlider)) seconds")
.font(.subheadline)
.foregroundColor(.secondary)
}
Slider(
value: $alertGracePeriodSlider,
in: minimumAlertGracePeriod...maximumAlertGracePeriod,
step: 5
) {
EmptyView()
} minimumValueLabel: {
Text("\(Int(minimumAlertGracePeriod))s")
.font(.caption)
.foregroundColor(.secondary)
} maximumValueLabel: {
Text("\(Int(maximumAlertGracePeriod))s")
.font(.caption)
.foregroundColor(.secondary)
} onEditingChanged: { editing in
alertGraceChanged(editing)
}
}
Spacer()
}
.toggleStyle(.switch)
+25 -2
View File
@@ -172,13 +172,15 @@ struct SummaryView: View {
VStack(alignment: .leading, spacing: 10) {
HStack(alignment: .center, spacing: 10) {
Circle()
.fill(server.pingable ? Color.green : Color.red)
.fill(statusTint)
.frame(width: 10, height: 10)
.help(statusTooltip)
Text(server.hostname)
.font(.system(size: 22, weight: .semibold))
statusBadge(server.pingable ? "Online" : "Offline", tint: server.pingable ? .green : .red)
statusBadge(statusText, tint: statusTint)
.help(statusTooltip)
}
HStack(spacing: 8) {
@@ -417,6 +419,27 @@ struct SummaryView: View {
return .green
}
}
private var statusTint: Color {
if !server.pingable {
return .red
}
if server.info?.rebootRequired == true {
return .yellow
}
return .green
}
private var statusText: String {
server.pingable ? "Online" : "Offline"
}
private var statusTooltip: String {
if !server.pingable {
return "This host is offline."
}
return server.info?.statusTooltip ?? "This host is online."
}
}
#Preview {
+8 -8
View File
@@ -2,6 +2,14 @@
<rss xmlns:sparkle="http://www.andymatuschak.org/xml-namespaces/sparkle" version="2.0">
<channel>
<title>iKeyMon</title>
<item>
<title>26.1.12</title>
<pubDate>Fri, 24 Apr 2026 19:17:55 +0200</pubDate>
<sparkle:version>189</sparkle:version>
<sparkle:shortVersionString>26.1.12</sparkle:shortVersionString>
<sparkle:minimumSystemVersion>15.2</sparkle:minimumSystemVersion>
<enclosure url="https://git.24unix.net/tracer/iKeyMon/releases/download/v26.1.12/iKeyMon-26.1.12.zip" length="3308758" type="application/octet-stream" sparkle:edSignature="auIwX5iy4DyrDSYHPFr696KD3UlFbFB1+1W55N2j868KkQJu36j/Wzj5UVursaakrBK49YrZRH4SqfClteQCAA=="/>
</item>
<item>
<title>26.1.11</title>
<pubDate>Tue, 21 Apr 2026 18:06:37 +0200</pubDate>
@@ -18,13 +26,5 @@
<sparkle:minimumSystemVersion>15.2</sparkle:minimumSystemVersion>
<enclosure url="https://git.24unix.net/tracer/iKeyMon/releases/download/v26.1.10/iKeyMon-26.1.10.zip" length="3193339" type="application/octet-stream" sparkle:edSignature="SwXVc4kmCUAec7VcDcoxDySDhGTXwdLz30q9SiyvaGK/P7cel3iljlvDd6Rc4/M1wtHoOZXXo+93lFhnLMB+Aw=="/>
</item>
<item>
<title>26.1.9</title>
<pubDate>Sun, 19 Apr 2026 23:04:07 +0200</pubDate>
<sparkle:version>181</sparkle:version>
<sparkle:shortVersionString>26.1.9</sparkle:shortVersionString>
<sparkle:minimumSystemVersion>15.2</sparkle:minimumSystemVersion>
<enclosure url="https://git.24unix.net/tracer/iKeyMon/releases/download/v26.1.9/iKeyMon-26.1.9.zip" length="3109488" type="application/octet-stream" sparkle:edSignature="ZV96uUMdYC/X90H3G10FMzmZHKUEWpe1geSe/5IBJ7EOCUmx7Mz352i6VMWumFnCtDD4jHo173W9eySUX9KvDA=="/>
</item>
</channel>
</rss>
+4 -4
View File
@@ -322,7 +322,7 @@
CODE_SIGN_ENTITLEMENTS = iKeyMon.entitlements;
CODE_SIGN_STYLE = Automatic;
COMBINE_HIDPI_IMAGES = YES;
CURRENT_PROJECT_VERSION = 187;
CURRENT_PROJECT_VERSION = 189;
DEVELOPMENT_ASSET_PATHS = "\"Preview Content\"";
DEVELOPMENT_TEAM = Q5486ZVAFT;
ENABLE_HARDENED_RUNTIME = YES;
@@ -337,7 +337,7 @@
"$(inherited)",
"@executable_path/../Frameworks",
);
MARKETING_VERSION = 26.1.11;
MARKETING_VERSION = 26.1.12;
PRODUCT_BUNDLE_IDENTIFIER = net.24unix.iKeyMon;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_EMIT_LOC_STRINGS = YES;
@@ -353,7 +353,7 @@
CODE_SIGN_ENTITLEMENTS = iKeyMon.entitlements;
CODE_SIGN_STYLE = Automatic;
COMBINE_HIDPI_IMAGES = YES;
CURRENT_PROJECT_VERSION = 187;
CURRENT_PROJECT_VERSION = 189;
DEVELOPMENT_ASSET_PATHS = "\"Preview Content\"";
DEVELOPMENT_TEAM = Q5486ZVAFT;
ENABLE_HARDENED_RUNTIME = YES;
@@ -368,7 +368,7 @@
"$(inherited)",
"@executable_path/../Frameworks",
);
MARKETING_VERSION = 26.1.11;
MARKETING_VERSION = 26.1.12;
PRODUCT_BUNDLE_IDENTIFIER = net.24unix.iKeyMon;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_EMIT_LOC_STRINGS = YES;
+1 -1
View File
@@ -1,3 +1,3 @@
{
"marketing_version": "26.1.11"
"marketing_version": "26.1.12"
}