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
This commit is contained in:
Micha
2026-01-03 15:48:01 +01:00
parent e7b776942b
commit 7a286c68e3
3 changed files with 97 additions and 12 deletions

View File

@@ -1,7 +1,10 @@
import Foundation import Foundation
import UserNotifications
enum PingService { 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 { guard let url = URL(string: "https://\(hostname)/api/v2/ping") else {
print("❌ [PingService] Invalid URL for \(hostname)") print("❌ [PingService] Invalid URL for \(hostname)")
return false return false
@@ -18,17 +21,49 @@ enum PingService {
if let responseString = String(data: data, encoding: .utf8) { if let responseString = String(data: data, encoding: .utf8) {
print("❌ [PingService] HTTP \(httpResponse.statusCode): \(responseString)") print("❌ [PingService] HTTP \(httpResponse.statusCode): \(responseString)")
} }
handlePingFailure(for: hostname, notificationsEnabled: notificationsEnabled)
return false return false
} }
if let result = try? JSONDecoder().decode([String: String].self, from: data), result["response"] == "pong" { if let result = try? JSONDecoder().decode([String: String].self, from: data), result["response"] == "pong" {
handlePingSuccess(for: hostname, notificationsEnabled: notificationsEnabled)
return true return true
} else { } else {
handlePingFailure(for: hostname, notificationsEnabled: notificationsEnabled)
return false return false
} }
} catch { } catch {
print("❌ [PingService] Error pinging \(hostname): \(error)") print("❌ [PingService] Error pinging \(hostname): \(error)")
handlePingFailure(for: hostname, notificationsEnabled: notificationsEnabled)
return false 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)
}
} }

View File

@@ -7,6 +7,7 @@
import SwiftUI import SwiftUI
import Combine import Combine
import UserNotifications
struct MainView: View { struct MainView: View {
@@ -20,9 +21,13 @@ struct MainView: View {
@State private var isFetchingInfo: Bool = false @State private var isFetchingInfo: Bool = false
@AppStorage("pingInterval") private var pingInterval: Int = 10 @AppStorage("pingInterval") private var pingInterval: Int = 10
@AppStorage("refreshInterval") private var refreshInterval: Int = 60 @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 refreshTimer: Timer.TimerPublisher?
@State private var refreshSubscription: AnyCancellable? @State private var refreshSubscription: AnyCancellable?
@State private var pingTimer: Timer? @State private var pingTimer: Timer?
@State private var lastRefreshInterval: Int?
@State private var previousServiceStates: [String: String] = [:]
private let serverOrderKey = MainView.serverOrderKeyStatic private let serverOrderKey = MainView.serverOrderKeyStatic
private let storedServersKey = MainView.storedServersKeyStatic private let storedServersKey = MainView.storedServersKeyStatic
@@ -102,6 +107,8 @@ struct MainView: View {
Button("Cancel", role: .cancel) {} Button("Cancel", role: .cancel) {}
} }
.onAppear { .onAppear {
requestNotificationPermissions()
let initialID: UUID? let initialID: UUID?
if let storedID = UserDefaults.standard.string(forKey: "selectedServerID"), if let storedID = UserDefaults.standard.string(forKey: "selectedServerID"),
let uuid = UUID(uuidString: storedID), let uuid = UUID(uuidString: storedID),
@@ -128,8 +135,10 @@ struct MainView: View {
.onChange(of: pingInterval) { _, _ in .onChange(of: pingInterval) { _, _ in
setupPingTimer() setupPingTimer()
} }
.onChange(of: refreshInterval) { _, _ in .onChange(of: refreshInterval) { oldValue, newValue in
setupRefreshTimer() if oldValue != newValue {
setupRefreshTimer()
}
} }
.frame(minWidth: 800, minHeight: 450) .frame(minWidth: 800, minHeight: 450)
} }
@@ -161,6 +170,7 @@ struct MainView: View {
var updated = servers[index] var updated = servers[index]
updated.info = info updated.info = info
servers[index] = updated servers[index] = updated
checkServiceStatusChanges(for: server.hostname, newInfo: info)
} }
} }
} catch { } catch {
@@ -221,7 +231,7 @@ struct MainView: View {
for (index, server) in servers.enumerated() { for (index, server) in servers.enumerated() {
Task { Task {
let apiKey = KeychainHelper.loadApiKey(for: server.hostname)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" 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 { await MainActor.run {
servers[index].pingable = pingable servers[index].pingable = pingable
} }
@@ -246,6 +256,7 @@ struct MainView: View {
} }
private func setupRefreshTimer() { private func setupRefreshTimer() {
refreshSubscription?.cancel()
refreshSubscription = nil refreshSubscription = nil
refreshTimer = Timer.publish(every: Double(refreshInterval), on: .main, in: .common) refreshTimer = Timer.publish(every: Double(refreshInterval), on: .main, in: .common)
refreshSubscription = refreshTimer?.autoconnect().sink { _ in refreshSubscription = refreshTimer?.autoconnect().sink { _ in
@@ -281,6 +292,46 @@ struct MainView: View {
return [] 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 { #Preview {

View File

@@ -260,19 +260,18 @@ private struct UpdatesPreferencesView: View {
} }
private struct NotificationsPreferencesView: View { private struct NotificationsPreferencesView: View {
var body: some View { @AppStorage("enableStatusNotifications") private var enableStatusNotifications: Bool = true
VStack(alignment: .leading, spacing: 12) { @AppStorage("enableAlertNotifications") private var enableAlertNotifications: Bool = true
Text("Notifications")
.font(.headline)
.padding(.bottom)
Text("Configure notification behavior here.") var body: some View {
.foregroundColor(.secondary) VStack(alignment: .leading, spacing: 18) {
Toggle("Status Notifications", isOn: $enableStatusNotifications)
Toggle("Alert Notifications", isOn: $enableAlertNotifications)
Spacer() Spacer()
} }
.toggleStyle(.switch)
.frame(maxWidth: .infinity, alignment: .leading) .frame(maxWidth: .infinity, alignment: .leading)
.padding()
} }
} }