import Foundation import SwiftUI import UserNotifications enum HealthStatus: String, Codable { case ok, warn, fail var title: String { switch self { case .ok: "Healthy"; case .warn: "Warning"; case .fail: "Critical" } } var symbol: String { switch self { case .ok: "checkmark.circle.fill"; case .warn: "exclamationmark.triangle.fill"; case .fail: "xmark.circle.fill" } } var color: Color { switch self { case .ok: .green; case .warn: .orange; case .fail: .red } } } struct HealthCounts: Codable { let ok: Int; let warn: Int; let fail: Int } struct HealthGroup: Codable, Identifiable { let name: String; let status: HealthStatus; let missing: [String]; var id: String { name } } struct HealthCheck: Codable, Identifiable { let name: String; let status: HealthStatus; let detail: String; let section: String; var id: String { "\(section)/\(name)" } } struct HealthReport: Codable { let schema: Int let host: String? let generatedAt: String? let overall: HealthStatus let counts: HealthCounts let groups: [HealthGroup] let checks: [HealthCheck] let diagnostics: String? } struct ServerProfile: Identifiable, Codable, Equatable { var id: UUID = UUID() var name: String = "New Server" var host: String = "" var user: String = "" var port: String = "22" var keyFile: String = "" var remoteCommand: String = "/usr/local/bin/pegasus-monitor-health-json" var enabled: Bool = true static func legacy() -> ServerProfile { let defaults = UserDefaults.standard return ServerProfile(name: "Pegasus", host: defaults.string(forKey: "server.host") ?? "192.168.1.2", user: defaults.string(forKey: "server.user") ?? "mag", port: defaults.string(forKey: "server.port") ?? "22", keyFile: defaults.string(forKey: "server.keyFile") ?? "", remoteCommand: "sudo -n /usr/local/bin/pegasus-health-json") } } struct ServerResult { var report: HealthReport? var error: String? var lastChecked: Date? var refreshing = false var status: HealthStatus { error != nil ? .fail : (report?.overall ?? .warn) } } @MainActor final class ServerMonitor: ObservableObject { @Published private(set) var servers: [ServerProfile] = [] @Published private(set) var results: [UUID: ServerResult] = [:] @Published var selectedID: UUID? @Published var intervalMinutes: Int { didSet { UserDefaults.standard.set(intervalMinutes, forKey: "monitor.intervalMinutes"); configureTimer() } } @Published var notifications: Bool { didSet { UserDefaults.standard.set(notifications, forKey: "monitor.notifications") } } private var timer: Timer? private var visited = Set() private let profilesKey = "monitor.serverProfiles.v1" private let selectedKey = "monitor.selectedServer.v1" init() { let defaults = UserDefaults.standard intervalMinutes = max(1, defaults.object(forKey: "monitor.intervalMinutes") as? Int ?? 5) notifications = defaults.object(forKey: "monitor.notifications") as? Bool ?? true if let bytes = defaults.data(forKey: profilesKey), let loaded = try? JSONDecoder().decode([ServerProfile].self, from: bytes) { servers = loaded } else { servers = [.legacy()] } if let stored = defaults.string(forKey: selectedKey), let id = UUID(uuidString: stored), servers.contains(where: { $0.id == id }) { selectedID = id } else { selectedID = servers.first?.id } configureTimer() Task { await refreshAll() } } var selected: ServerProfile? { servers.first(where: { $0.id == selectedID }) } var selectedResult: ServerResult? { selectedID.flatMap { results[$0] } } var report: HealthReport? { selectedResult?.report } var status: HealthStatus { let statuses = servers.filter(\.enabled).map { results[$0.id]?.status ?? .warn } if statuses.contains(.fail) { return .fail } if statuses.contains(.warn) { return .warn } return statuses.isEmpty ? .warn : .ok } var aggregate: HealthCounts { let active = servers.filter(\.enabled) return HealthCounts(ok: active.reduce(0) { $0 + (results[$1.id]?.report?.counts.ok ?? 0) }, warn: active.reduce(0) { $0 + (results[$1.id]?.report?.counts.warn ?? 0) }, fail: active.reduce(0) { $0 + (results[$1.id]?.report?.counts.fail ?? (results[$1.id]?.error == nil ? 0 : 1)) }) } var offlineCount: Int { servers.filter { $0.enabled && results[$0.id]?.error != nil }.count } func select(_ id: UUID) { guard servers.contains(where: { $0.id == id }) else { return } selectedID = id UserDefaults.standard.set(id.uuidString, forKey: selectedKey) } func save(_ profile: ServerProfile) { if let index = servers.firstIndex(where: { $0.id == profile.id }) { servers[index] = profile } else { servers.append(profile) } persist() select(profile.id) Task { await refresh(id: profile.id) } } func remove(_ id: UUID) { servers.removeAll { $0.id == id } results.removeValue(forKey: id) visited.remove(id) if selectedID == id { selectedID = servers.first?.id } persist() } func move(from source: IndexSet, to destination: Int) { servers.move(fromOffsets: source, toOffset: destination) persist() } private func persist() { if let bytes = try? JSONEncoder().encode(servers) { UserDefaults.standard.set(bytes, forKey: profilesKey) } UserDefaults.standard.set(selectedID?.uuidString, forKey: selectedKey) } func configureTimer() { timer?.invalidate() timer = Timer.scheduledTimer(withTimeInterval: TimeInterval(max(1, intervalMinutes) * 60), repeats: true) { [weak self] _ in Task { @MainActor [weak self] in await self?.refreshAll() } } } func refreshAll() async { await withTaskGroup(of: Void.self) { group in for server in servers where server.enabled { group.addTask { await self.refresh(id: server.id) } } } } func refreshSelected() async { if let id = selectedID { await refresh(id: id) } } func refresh(id: UUID) async { guard let server = servers.first(where: { $0.id == id }), server.enabled else { return } if results[id]?.refreshing == true { return } var state = results[id] ?? ServerResult() state.refreshing = true results[id] = state let previous = state.status do { let data = try await SSHRunner.run(host: server.host, user: server.user, port: server.port, keyFile: server.keyFile, remoteCommand: server.remoteCommand) let report = try JSONDecoder().decode(HealthReport.self, from: data) guard report.schema == 1 else { throw SSHError.invalidResponse } state.report = report state.error = nil if notifications && visited.contains(id) && previous == .ok && report.overall != .ok { notify(title: "\(server.name): \(report.overall.title)", message: "\(report.counts.fail) failures, \(report.counts.warn) warnings") } } catch { state.error = error.localizedDescription if notifications && visited.contains(id) && previous != .fail { notify(title: "\(server.name) unreachable", message: error.localizedDescription) } } state.lastChecked = Date() state.refreshing = false results[id] = state visited.insert(id) } private func notify(title: String, message: String) { let center = UNUserNotificationCenter.current() center.requestAuthorization(options: [.alert, .sound]) { allowed, _ in guard allowed else { return } let content = UNMutableNotificationContent() content.title = title content.body = message center.add(UNNotificationRequest(identifier: UUID().uuidString, content: content, trigger: nil)) } } } enum SSHError: LocalizedError { case invalidSettings, invalidResponse, process(String) var errorDescription: String? { switch self { case .invalidSettings: "Invalid SSH settings (host, user, port or command)." case .invalidResponse: "Invalid health JSON. Install the remote health adapter." case .process(let text): text } } } enum SSHRunner { static func run(host: String, user: String, port: String, keyFile: String, remoteCommand: String) async throws -> Data { guard !host.isEmpty, !user.isEmpty, let number = Int(port), (1...65535).contains(number), host.range(of: #"^[A-Za-z0-9._:-]+$"#, options: .regularExpression) != nil, user.range(of: #"^[A-Za-z0-9_-]+$"#, options: .regularExpression) != nil, !remoteCommand.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { throw SSHError.invalidSettings } return try await Task.detached(priority: .utility) { let process = Process() process.executableURL = URL(fileURLWithPath: "/usr/bin/ssh") var args = ["-T", "-o", "BatchMode=yes", "-o", "StrictHostKeyChecking=yes", "-o", "ConnectTimeout=8", "-o", "ServerAliveInterval=5", "-o", "ServerAliveCountMax=2", "-p", String(number)] if !keyFile.isEmpty { args += ["-i", (keyFile as NSString).expandingTildeInPath] } args += ["\(user)@\(host)", remoteCommand] process.arguments = args let output = Pipe() let errors = Pipe() process.standardOutput = output process.standardError = errors try process.run() let stdout = Task.detached { output.fileHandleForReading.readDataToEndOfFile() } let stderr = Task.detached { errors.fileHandleForReading.readDataToEndOfFile() } let timeout = Task.detached { try? await Task.sleep(for: .seconds(90)) if process.isRunning { process.terminate() } } let bytes = await stdout.value let errorBytes = await stderr.value process.waitUntilExit() timeout.cancel() if bytes.isEmpty { let message = String(data: errorBytes, encoding: .utf8) ?? "SSH returned no data" throw SSHError.process(message.isEmpty ? "SSH returned no data" : message) } return bytes }.value } }