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 uptimeSeconds: Int? var uptimeLabel: String? { guard let uptimeSeconds, uptimeSeconds >= 0 else { return nil } let days = uptimeSeconds / 86400 let hours = (uptimeSeconds % 86400) / 3600 let minutes = (uptimeSeconds % 3600) / 60 if days > 0 { return "\(days)d \(hours)h \(minutes)m" } if hours > 0 { return "\(hours)h \(minutes)m" } return "\(minutes)m" } let overall: HealthStatus let counts: HealthCounts let groups: [HealthGroup] let checks: [HealthCheck] let diagnostics: String? let services: [DiscoveredService]? } // Service discovery is optional so legacy Pegasus health JSON stays compatible. struct DiscoveredService: Codable, Identifiable, Equatable { let name: String let state: String let detail: String var id: String { name } var isInstalled: Bool { state != "not_installed" } var isHealthy: Bool { state == "running" } var symbol: String { switch state { case "running": "checkmark.circle.fill" case "failed", "required_missing": "xmark.circle.fill" case "stopped": "exclamationmark.triangle.fill" case "disabled": "pause.circle" case "not_installed": "minus.circle" default: "questionmark.circle" } } var color: Color { switch state { case "running": .green case "failed", "required_missing": .red case "stopped": .orange default: .secondary } } var title: String { switch state { case "running": "Running" case "failed": "Failed" case "stopped": "Stopped" case "disabled": "Disabled" case "not_installed": "Not installed" case "required_missing": "Required — missing" default: "Unknown" } } } 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 var requiredServices: [String] = [] var discoverServices: Bool = true // Preserve profiles saved by 1.1, which did not include service preferences. enum CodingKeys: String, CodingKey { case id, name, host, user, port, keyFile, remoteCommand, enabled, requiredServices, discoverServices } init(id: UUID = UUID(), name: String = "New Server", host: String = "", user: String = "", port: String = "22", keyFile: String = "", remoteCommand: String = "/usr/local/bin/pegasus-monitor-health-json", enabled: Bool = true, requiredServices: [String] = [], discoverServices: Bool = true) { self.id = id; self.name = name; self.host = host; self.user = user; self.port = port self.keyFile = keyFile; self.remoteCommand = remoteCommand; self.enabled = enabled self.requiredServices = requiredServices; self.discoverServices = discoverServices } init(from decoder: Decoder) throws { let c = try decoder.container(keyedBy: CodingKeys.self) id = try c.decode(UUID.self, forKey: .id) name = try c.decode(String.self, forKey: .name) host = try c.decode(String.self, forKey: .host) user = try c.decode(String.self, forKey: .user) port = try c.decode(String.self, forKey: .port) keyFile = try c.decode(String.self, forKey: .keyFile) remoteCommand = try c.decode(String.self, forKey: .remoteCommand) enabled = try c.decode(Bool.self, forKey: .enabled) requiredServices = try c.decodeIfPresent([String].self, forKey: .requiredServices) ?? [] discoverServices = try c.decodeIfPresent(Bool.self, forKey: .discoverServices) ?? 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 selectedServices: [DiscoveredService] { guard let profile = selected else { return [] } return discoveredServices(for: profile.id).filter { profile.requiredServices.contains($0.name) || (profile.discoverServices && $0.state != "not_installed") } } func discoveredServices(for id: UUID) -> [DiscoveredService] { guard let profile = servers.first(where: { $0.id == id }) else { return [] } let discovered = results[id]?.report?.services ?? [] var byName = Dictionary(uniqueKeysWithValues: discovered.map { ($0.name, $0) }) for name in profile.requiredServices where byName[name] == nil { byName[name] = DiscoveredService(name: name, state: "not_installed", detail: "Not reported by agent") } return byName.values.sorted { $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending } } func serviceStatus(_ item: DiscoveredService, required: Bool) -> HealthStatus? { if required && !item.isHealthy { return .fail } if item.state == "failed" { return .fail } if item.state == "stopped" { return .warn } return nil } func applyServicePolicy(_ report: HealthReport, for profile: ServerProfile) -> HealthReport { let services = report.services ?? [] // If an older agent has no service discovery, do not invent a healthy state. // Explicit required services with no reported data are a failure. let known = Dictionary(uniqueKeysWithValues: services.map { ($0.name, $0) }) var extra: [HealthCheck] = [] for required in Set(profile.requiredServices) { let item = known[required] if item?.isHealthy != true { let description = item?.title ?? "Not reported by monitoring agent" extra.append(HealthCheck(name: "Required: \(required)", status: .fail, detail: description, section: "Required services")) } } let existing = Set(report.checks.map { $0.id }) extra.removeAll { existing.contains($0.id) } guard !extra.isEmpty else { return report } let checks = report.checks + extra let counts = HealthCounts(ok: report.counts.ok, warn: report.counts.warn, fail: report.counts.fail + extra.count) let groups = report.groups + [HealthGroup(name: "Required services", status: .fail, missing: extra.map(\.name))] return HealthReport(schema: report.schema, host: report.host, generatedAt: report.generatedAt, uptimeSeconds: report.uptimeSeconds, overall: .fail, counts: counts, groups: groups, checks: checks, diagnostics: report.diagnostics, services: report.services) } 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) } results.removeValue(forKey: profile.id) 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 decoded = try JSONDecoder().decode(HealthReport.self, from: data) let report = applyServicePolicy(decoded, for: server) 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 } }