prole/proleStatus/Sources/ServiceChecker.swift
2025-11-17 20:52:19 -08:00

132 lines
5.4 KiB
Swift

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
// Last error messages (for tooltips when red)
private(set) var svcError: String? = nil
private(set) var raspberryError: String? = nil
private(set) var piError: String? = nil
private(set) var localError: String? = nil
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()
// clear previous errors before a new round
svcError = nil; raspberryError = nil; piError = nil; localError = nil
group.enter(); tcpPing(host: "svc.prole.org", port: 443) { [weak self] ok, ms, err in
self?.svcReachable = ok; self?.svcLatency = ms; self?.svcError = err; group.leave()
}
group.enter(); tcpPing(host: "raspberry.prole.org", port: 6443) { [weak self] ok, ms, err in
self?.raspberryReachable = ok; self?.raspberryLatency = ms; self?.raspberryError = err; group.leave()
}
group.enter(); tcpPing(host: "pi.prole.org", port: 6443) { [weak self] ok, ms, err in
self?.piReachable = ok; self?.piLatency = ms; self?.piError = err; group.leave()
}
group.enter(); tcpPing(host: "localhost", port: 6443) { [weak self] ok, ms, err in
self?.localReachable = ok; self?.localLatency = ms; self?.localError = err; 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, String?) -> 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), nil)
conn.cancel()
case .failed(let error):
completion(false, -1, Self.describeNWError(error))
case .cancelled:
// If cancelled before ready, likely timed out
completion(false, -1, "connection cancelled (possible timeout)")
default: break
}
}
conn.start(queue: queue)
// Timeout
queue.asyncAfter(deadline: .now() + timeout) {
conn.cancel()
}
}
// Tooltips
func tooltipForSvc() -> String {
if svcReachable { return "svc.prole.org:443 — reachable (\(svcLatency) ms)" }
var s = "svc.prole.org:443 — unreachable"
if let e = svcError { s += "\nError: \(e)" }
return s
}
func tooltipForAggregateK3s() -> String {
var r = raspberryReachable ? "raspberry ✓ (\(raspberryLatency) ms)" : "raspberry ✗"
var p = piReachable ? "pi ✓ (\(piLatency) ms)" : "pi ✗"
if !raspberryReachable, let e = raspberryError { r += " — \(e)" }
if !piReachable, let e = piError { p += " — \(e)" }
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 {
if localReachable { return "k3d localhost:6443 — reachable (\(localLatency) ms)" }
var s = "k3d localhost:6443 — unreachable"
if let e = localError { s += "\nError: \(e)" }
return s
}
private static func describeNWError(_ error: NWError) -> String {
switch error {
case .posix(let code): return "POSIX \(code.rawValue): \(code)"
case .dns(let code): return "DNS \(code): \(code)"
case .tls(let status): return "TLS/OSStatus \(status)"
case .wifiAware(let reason): return "Wi-Fi Aware: \(reason)"
@unknown default: return "Unknown network error"
}
}
}