diff --git a/CHANGELOG.md b/CHANGELOG.md index 0c282d5..913366c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/Sources/Model/API/PingService.swift b/Sources/Model/API/PingService.swift index 9703b35..b92a942 100644 --- a/Sources/Model/API/PingService.swift +++ b/Sources/Model/API/PingService.swift @@ -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 diff --git a/Sources/Model/API/ServerInfo.swift b/Sources/Model/API/ServerInfo.swift index 71b7299..42d44d4 100644 --- a/Sources/Model/API/ServerInfo.swift +++ b/Sources/Model/API/ServerInfo.swift @@ -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) diff --git a/Sources/Views/MainView.swift b/Sources/Views/MainView.swift index d874aa3..a35f052 100644 --- a/Sources/Views/MainView.swift +++ b/Sources/Views/MainView.swift @@ -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 { - sendServiceNotification(service: port.service, hostname: hostname, status: "online") + 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 } } diff --git a/Sources/Views/PreferencesView.swift b/Sources/Views/PreferencesView.swift index 9a92dea..7a0d7d4 100644 --- a/Sources/Views/PreferencesView.swift +++ b/Sources/Views/PreferencesView.swift @@ -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) diff --git a/Sources/Views/Tabs/SummaryView.swift b/Sources/Views/Tabs/SummaryView.swift index 531e3ea..593fa94 100644 --- a/Sources/Views/Tabs/SummaryView.swift +++ b/Sources/Views/Tabs/SummaryView.swift @@ -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 {