From 7a286c68e31e0fe2d9aeba1b2969d992f1af0856 Mon Sep 17 00:00:00 2001 From: Micha Date: Sat, 3 Jan 2026 15:48:01 +0100 Subject: [PATCH] feat: add status notifications for server monitoring - Add notification preferences (Status Notifications and Alert Notifications toggles) - Implement ping failure/recovery notifications when servers go offline/online - Track individual service status changes and notify when services fail - Request notification permissions on app launch - Services like DNS, FTP, SSH, etc. now trigger alerts when status changes - Notifications only sent when settings are enabled Changes: - PreferencesView: Add NotificationsPreferencesView with two toggles - PingService: Add notification support with state tracking for ping events - MainView: Add service status monitoring with change detection - Track previous service states to detect transitions --- Sources/Model/API/PingService.swift | 37 ++++++++++++++++++- Sources/Views/MainView.swift | 57 +++++++++++++++++++++++++++-- Sources/Views/PreferencesView.swift | 15 ++++---- 3 files changed, 97 insertions(+), 12 deletions(-) diff --git a/Sources/Model/API/PingService.swift b/Sources/Model/API/PingService.swift index fa476d3..66f9a4c 100644 --- a/Sources/Model/API/PingService.swift +++ b/Sources/Model/API/PingService.swift @@ -1,7 +1,10 @@ import Foundation +import UserNotifications enum PingService { - static func ping(hostname: String, apiKey: String) async -> Bool { + private static var previousPingStates: [String: Bool] = [:] + + static func ping(hostname: String, apiKey: String, notificationsEnabled: Bool = true) async -> Bool { guard let url = URL(string: "https://\(hostname)/api/v2/ping") else { print("❌ [PingService] Invalid URL for \(hostname)") return false @@ -18,17 +21,49 @@ enum PingService { if let responseString = String(data: data, encoding: .utf8) { print("❌ [PingService] HTTP \(httpResponse.statusCode): \(responseString)") } + handlePingFailure(for: hostname, notificationsEnabled: notificationsEnabled) return false } if let result = try? JSONDecoder().decode([String: String].self, from: data), result["response"] == "pong" { + handlePingSuccess(for: hostname, notificationsEnabled: notificationsEnabled) return true } else { + handlePingFailure(for: hostname, notificationsEnabled: notificationsEnabled) return false } } catch { print("❌ [PingService] Error pinging \(hostname): \(error)") + handlePingFailure(for: hostname, notificationsEnabled: notificationsEnabled) return false } } + + private static func handlePingSuccess(for hostname: String, notificationsEnabled: Bool) { + let wasPreviouslyDown = previousPingStates[hostname] == false + previousPingStates[hostname] = true + + if wasPreviouslyDown && notificationsEnabled { + sendNotification(title: "Server Online", body: "\(hostname) is now online") + } + } + + private static func handlePingFailure(for hostname: String, notificationsEnabled: Bool) { + let wasPreviouslyUp = previousPingStates[hostname] != false + previousPingStates[hostname] = false + + if wasPreviouslyUp && notificationsEnabled { + sendNotification(title: "Server Offline", body: "\(hostname) is offline") + } + } + + private static func sendNotification(title: String, body: String) { + let content = UNMutableNotificationContent() + content.title = title + content.body = body + content.sound = .default + + let request = UNNotificationRequest(identifier: UUID().uuidString, content: content, trigger: nil) + UNUserNotificationCenter.current().add(request) + } } diff --git a/Sources/Views/MainView.swift b/Sources/Views/MainView.swift index ff2caf8..f8d5685 100644 --- a/Sources/Views/MainView.swift +++ b/Sources/Views/MainView.swift @@ -7,6 +7,7 @@ import SwiftUI import Combine +import UserNotifications struct MainView: View { @@ -20,9 +21,13 @@ struct MainView: View { @State private var isFetchingInfo: Bool = false @AppStorage("pingInterval") private var pingInterval: Int = 10 @AppStorage("refreshInterval") private var refreshInterval: Int = 60 + @AppStorage("enableStatusNotifications") private var enableStatusNotifications: Bool = true + @AppStorage("enableAlertNotifications") private var enableAlertNotifications: Bool = true @State private var refreshTimer: Timer.TimerPublisher? @State private var refreshSubscription: AnyCancellable? @State private var pingTimer: Timer? + @State private var lastRefreshInterval: Int? + @State private var previousServiceStates: [String: String] = [:] private let serverOrderKey = MainView.serverOrderKeyStatic private let storedServersKey = MainView.storedServersKeyStatic @@ -102,6 +107,8 @@ struct MainView: View { Button("Cancel", role: .cancel) {} } .onAppear { + requestNotificationPermissions() + let initialID: UUID? if let storedID = UserDefaults.standard.string(forKey: "selectedServerID"), let uuid = UUID(uuidString: storedID), @@ -128,8 +135,10 @@ struct MainView: View { .onChange(of: pingInterval) { _, _ in setupPingTimer() } - .onChange(of: refreshInterval) { _, _ in - setupRefreshTimer() + .onChange(of: refreshInterval) { oldValue, newValue in + if oldValue != newValue { + setupRefreshTimer() + } } .frame(minWidth: 800, minHeight: 450) } @@ -161,6 +170,7 @@ struct MainView: View { var updated = servers[index] updated.info = info servers[index] = updated + checkServiceStatusChanges(for: server.hostname, newInfo: info) } } } catch { @@ -221,7 +231,7 @@ struct MainView: View { for (index, server) in servers.enumerated() { Task { let apiKey = KeychainHelper.loadApiKey(for: server.hostname)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" - let pingable = await PingService.ping(hostname: server.hostname, apiKey: apiKey) + let pingable = await PingService.ping(hostname: server.hostname, apiKey: apiKey, notificationsEnabled: enableStatusNotifications) await MainActor.run { servers[index].pingable = pingable } @@ -246,6 +256,7 @@ struct MainView: View { } private func setupRefreshTimer() { + refreshSubscription?.cancel() refreshSubscription = nil refreshTimer = Timer.publish(every: Double(refreshInterval), on: .main, in: .common) refreshSubscription = refreshTimer?.autoconnect().sink { _ in @@ -281,6 +292,46 @@ struct MainView: View { return [] } } + + private func requestNotificationPermissions() { + Task { + do { + try await UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .sound]) + } catch { + print("❌ [MainView] Failed to request notification permissions: \(error)") + } + } + } + + private func checkServiceStatusChanges(for hostname: String, newInfo: ServerInfo) { + guard let ports = newInfo.ports else { return } + + for port in ports { + let key = "\(hostname)-\(port.id)" + let previousStatus = previousServiceStates[key] + let currentStatus = port.status + + previousServiceStates[key] = currentStatus + + 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") + } + } + } + } + + private func sendServiceNotification(service: String, hostname: String, status: String) { + let content = UNMutableNotificationContent() + content.title = "\(service) \(status.uppercased())" + content.body = "\(service) on \(hostname) is \(status)" + content.sound = .default + + let request = UNNotificationRequest(identifier: UUID().uuidString, content: content, trigger: nil) + UNUserNotificationCenter.current().add(request) + } } #Preview { diff --git a/Sources/Views/PreferencesView.swift b/Sources/Views/PreferencesView.swift index 1fd1f29..d1aff91 100644 --- a/Sources/Views/PreferencesView.swift +++ b/Sources/Views/PreferencesView.swift @@ -260,19 +260,18 @@ private struct UpdatesPreferencesView: View { } private struct NotificationsPreferencesView: View { - var body: some View { - VStack(alignment: .leading, spacing: 12) { - Text("Notifications") - .font(.headline) - .padding(.bottom) + @AppStorage("enableStatusNotifications") private var enableStatusNotifications: Bool = true + @AppStorage("enableAlertNotifications") private var enableAlertNotifications: Bool = true - Text("Configure notification behavior here.") - .foregroundColor(.secondary) + var body: some View { + VStack(alignment: .leading, spacing: 18) { + Toggle("Status Notifications", isOn: $enableStatusNotifications) + Toggle("Alert Notifications", isOn: $enableAlertNotifications) Spacer() } + .toggleStyle(.switch) .frame(maxWidth: .infinity, alignment: .leading) - .padding() } }