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 SSHSettings { @AppStorage("server.host") var host = "192.168.1.2" @AppStorage("server.user") var user = "mag" @AppStorage("server.port") var port = "22" @AppStorage("server.keyFile") var keyFile = "" @AppStorage("monitor.intervalMinutes") var intervalMinutes = 5 @AppStorage("monitor.notifications") var notifications = true } @MainActor final class ServerMonitor: ObservableObject { @Published private(set) var report: HealthReport? @Published private(set) var status: HealthStatus = .warn @Published private(set) var isRefreshing = false @Published private(set) var lastChecked: Date? @Published private(set) var lastError: String? @Published var showDiagnostics = false @Published var showSettings = false private var settings = SSHSettings() private var timer: Timer? private var everLoaded = false init() { configureTimer() Task { await refresh() } } func configureTimer() { timer?.invalidate() let minutes = max(1, settings.intervalMinutes) timer = Timer.scheduledTimer(withTimeInterval: TimeInterval(minutes * 60), repeats: true) { [weak self] _ in Task { @MainActor in await self?.refresh() } } } func refresh() async { guard !isRefreshing else { return } isRefreshing = true defer { isRefreshing = false; lastChecked = Date() } let previous = report?.overall do { let data = try await SSHRunner.run(host: settings.host, user: settings.user, port: settings.port, keyFile: settings.keyFile, remoteCommand: "sudo -n /usr/local/bin/pegasus-health-json") let value = try JSONDecoder().decode(HealthReport.self, from: data) guard value.schema == 1, value.groups.count == 14 else { throw SSHError.invalidResponse } report = value status = value.overall lastError = nil if settings.notifications && everLoaded && previous == .ok && value.overall != .ok { notify(title: "Pegasus \(value.overall.title)", message: "\(value.counts.fail) failures, \(value.counts.warn) warnings") } everLoaded = true } catch { lastError = error.localizedDescription status = .fail if settings.notifications && everLoaded && previous != .fail { notify(title: "Pegasus Unreachable", message: error.localizedDescription) } } } func showSystemSettings() { NSApp.sendAction(Selector(("showSettingsWindow:")), to: nil, from: nil) } 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, timeout, invalidResponse, process(String) var errorDescription: String? { switch self { case .invalidSettings: "Invalid SSH settings. Check host, user and port." case .timeout: "SSH connection timed out." case .invalidResponse: "Server returned invalid health JSON. Install the server 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 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() // Drain both pipes concurrently so verbose output cannot block the child. 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() // Health status exits 1/2 intentionally; JSON remains authoritative. if bytes.isEmpty { let errorMessage = String(data: errorBytes, encoding: .utf8) ?? "SSH returned no data" throw SSHError.process(errorMessage.isEmpty ? "SSH returned no data" : errorMessage) } return bytes }.value } }