import Foundation import Network final class ServiceChecker { static let statusDidChangeNotification = Notification.Name("ServiceChecker.statusDidChange") static let forceRefreshNotification = Notification.Name("ServiceChecker.forceRefresh") private let queue = DispatchQueue(label: "prole.status.checker") private var timer: DispatchSourceTimer? // Public reachability flags private(set) var svcReachable = false private(set) var raspberryReachable = false private(set) var piReachable = false private(set) var localReachable = false // Latency measurements (ms) private(set) var svcLatency: Int = -1 private(set) var raspberryLatency: Int = -1 private(set) var piLatency: Int = -1 private(set) var localLatency: Int = -1 init() { NotificationCenter.default.addObserver(self, selector: #selector(forceRefresh), name: Self.forceRefreshNotification, object: nil) } func start(interval: TimeInterval = 15) { timer?.cancel() let t = DispatchSource.makeTimerSource(queue: queue) t.schedule(deadline: .now(), repeating: interval) t.setEventHandler { [weak self] in self?.refreshAll() } t.resume() timer = t } @objc private func forceRefresh() { queue.async { self.refreshAll() } } private func refreshAll() { let group = DispatchGroup() group.enter(); tcpPing(host: "svc.prole.org", port: 443) { [weak self] ok, ms in self?.svcReachable = ok; self?.svcLatency = ms; group.leave() } group.enter(); tcpPing(host: "raspberry.prole.org", port: 6443) { [weak self] ok, ms in self?.raspberryReachable = ok; self?.raspberryLatency = ms; group.leave() } group.enter(); tcpPing(host: "pi.prole.org", port: 6443) { [weak self] ok, ms in self?.piReachable = ok; self?.piLatency = ms; group.leave() } group.enter(); tcpPing(host: "localhost", port: 6443) { [weak self] ok, ms in self?.localReachable = ok; self?.localLatency = ms; group.leave() } group.notify(queue: .main) { NotificationCenter.default.post(name: Self.statusDidChangeNotification, object: self) } } private func tcpPing(host: String, port: UInt16, timeout: TimeInterval = 2.0, completion: @escaping (Bool, Int) -> Void) { let start = DispatchTime.now() let params = NWParameters.tcp params.allowLocalEndpointReuse = true let endpoint = NWEndpoint.hostPort(host: .name(host, nil), port: .init(integerLiteral: port)) let conn = NWConnection(to: endpoint, using: params) conn.stateUpdateHandler = { state in switch state { case .ready: let elapsed = DispatchTime.now().uptimeNanoseconds - start.uptimeNanoseconds completion(true, Int(Double(elapsed) / 1_000_000.0)) conn.cancel() case .failed, .cancelled: completion(false, -1) default: break } } conn.start(queue: queue) // Timeout queue.asyncAfter(deadline: .now() + timeout) { conn.cancel() } } // Tooltips func tooltipForSvc() -> String { "svc.prole.org:443 — " + (svcReachable ? "reachable (\(svcLatency) ms)" : "unreachable") } func tooltipForAggregateK3s() -> String { let r = raspberryReachable ? "raspberry ✓ (\(raspberryLatency) ms)" : "raspberry ✗" let p = piReachable ? "pi ✓ (\(piLatency) ms)" : "pi ✗" let overall: String switch (raspberryReachable, piReachable) { case (true, true): overall = "overall: green" case (true, false), (false, true): overall = "overall: yellow" default: overall = "overall: red" } return "k3s aggregate — \(overall)\n\(r)\n\(p)" } func tooltipForLocal() -> String { "k3d localhost:6443 — " + (localReachable ? "reachable (\(localLatency) ms)" : "unreachable") } }