prole/prole-app/Sources/ServiceChecker.swift
chrisfu b2cbe72c4a ServiceChecker: background refresh + Preferences reload; PF crash fix; Prefs Save commit inputs
- ServiceChecker now runs as a background timer task (30s), skips overlapping runs, and caches per-endpoint state from Preferences (services + kubernetes). Legacy UI flags are kept in sync for StatusView/Overlay.
- Auto-reload on Preferences save: ServiceChecker listens to Config.didChange, clears caches, invalidates freshness, and triggers an immediate refresh.
- Preferences: ensure in-progress text edits are committed before saving (endEditing) for Kubernetes, Services, and Ports tabs.
- PortForwardManager: fix crash in termination handler by reading terminationStatus from the provided Process instance, then updating state on the manager queue.
- Minor: keep existing UI wiring; logging intact for diagnostics.

TODO: remove (list unused code paths)
- Config.primaryKube (unused in prole-app)
- Config.string(_:, default:) (unused in prole-app)
- Config.int(_:, default:) (unused in prole-app)
- PortForwardManager.parsePFCommand tuple parts `modeBackground` and `keepAlive` (never read by callers)
- Config.pfModeBackground and Config.pfKeepAlive (currently ineffective because their values aren’t used downstream)
2025-12-09 18:16:14 -08:00

253 lines
11 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?
private var isRunning: Bool = false
private var lastRefreshAt: Date = .distantPast
private let refreshInterval: TimeInterval = 30
// 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
// Generic per-endpoint state so UI can query dynamically from Preferences
struct EndpointState: Equatable {
var reachable: Bool
var latencyMs: Int
var error: String?
var lastChecked: Date
}
// Cached states (by logical keys)
// services: key = service name (Config.ServiceEndpoint.name)
// kubes: key = host:port string
private(set) var serviceStates: [String: EndpointState] = [:]
private(set) var kubeStates: [String: EndpointState] = [:]
init() {
NotificationCenter.default.addObserver(self, selector: #selector(forceRefresh), name: Self.forceRefreshNotification, object: nil)
// When configuration changes, invalidate cache and reload immediately
NotificationCenter.default.addObserver(self, selector: #selector(configDidChange), name: Config.didChangeNotification, object: nil)
}
func start(interval: TimeInterval = 30) {
timer?.cancel()
let t = DispatchSource.makeTimerSource(queue: queue)
t.schedule(deadline: .now(), repeating: interval)
t.setEventHandler { [weak self] in self?.refreshAll() }
dlog("ServiceChecker: starting timer, interval=\(interval)s")
t.resume()
timer = t
}
@objc private func forceRefresh() { queue.async { self.refreshAll() } }
@objc private func configDidChange() {
queue.async {
// Invalidate freshness so the next refresh runs immediately
self.lastRefreshAt = .distantPast
// Clear caches so UI doesn't briefly show stale dynamic entries
self.serviceStates.removeAll()
self.kubeStates.removeAll()
self.refreshAll()
}
}
private func refreshAll() {
// Ensure we don't overlap and we don't rerun faster than every 30s
if isRunning {
dlog("ServiceChecker: skip — previous refresh still running")
return
}
let now = Date()
if now.timeIntervalSince(lastRefreshAt) < refreshInterval {
dlog("ServiceChecker: skip — cache still fresh (< \(Int(refreshInterval))s)")
return
}
isRunning = true
dlog("ServiceChecker: begin refresh round")
let group = DispatchGroup()
// clear previous errors before a new round (legacy fields)
svcError = nil; raspberryError = nil; piError = nil; localError = nil
let cfg = Config.shared
// Iterate Services from Preferences
for svc in cfg.services {
group.enter()
tcpPing(host: svc.host, port: UInt16(svc.port)) { [weak self] ok, ms, err in
guard let self = self else { group.leave(); return }
let key = svc.name
let st = EndpointState(reachable: ok, latencyMs: ms, error: err, lastChecked: Date())
self.serviceStates[key] = st
// Update legacy convenience fields for UI compatibility
if let primary = cfg.primaryService, primary.name == svc.name {
self.svcReachable = ok
self.svcLatency = ms
self.svcError = err
}
if let local = cfg.localService, local.name == svc.name {
self.localReachable = ok
self.localLatency = ms
self.localError = err
}
group.leave()
}
}
// Iterate Kubernetes endpoints from Preferences
let kubes = cfg.kubernetes
if kubes.isEmpty {
raspberryReachable = false; raspberryLatency = -1; raspberryError = "no kubernetes endpoints configured"
piReachable = false; piLatency = -1; piError = nil
}
for (idx, k) in kubes.enumerated() {
group.enter()
tcpPing(host: k.host, port: UInt16(k.port)) { [weak self] ok, ms, err in
guard let self = self else { group.leave(); return }
let key = "\(k.host):\(k.port)"
let st = EndpointState(reachable: ok, latencyMs: ms, error: err, lastChecked: Date())
self.kubeStates[key] = st
// Maintain two-LED aggregate legacy fields for first two entries
if idx == 0 {
self.raspberryReachable = ok
self.raspberryLatency = ms
self.raspberryError = err
} else if idx == 1 {
self.piReachable = ok
self.piLatency = ms
self.piError = err
}
group.leave()
}
}
group.notify(queue: .main) {
self.lastRefreshAt = Date()
self.isRunning = false
dlog("ServiceChecker: refresh round complete → posting statusDidChangeNotification")
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) {
dlog("tcpPing: attempting \(host):\(port) timeout=\(timeout)s")
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)
// Ensure completion is invoked exactly once
var finished = false
func finishOnce(_ ok: Bool, _ ms: Int, _ err: String?, reason: String) {
// All state updates and timeout run on `queue` so this is serialized
if finished { return }
finished = true
dlog("tcpPing: finishOnce(\(host):\(port)) reason=\(reason) ok=\(ok) ms=\(ms) err=\(err ?? "nil")")
completion(ok, ms, err)
}
// Prepare timeout work item so we can cancel it on success/failure
let timeoutWork = DispatchWorkItem { [weak conn] in
if finished { return }
dlog("tcpPing: timeout reached for \(host):\(port); cancelling connection")
conn?.cancel()
finishOnce(false, -1, "connection timed out", reason: "timeout")
}
conn.stateUpdateHandler = { state in
switch state {
case .ready:
let elapsed = DispatchTime.now().uptimeNanoseconds - start.uptimeNanoseconds
let ms = Int(Double(elapsed) / 1_000_000.0)
dlog("tcpPing: READY \(host):\(port) in \(ms) ms")
timeoutWork.cancel()
finishOnce(true, ms, nil, reason: "ready")
conn.cancel() // will emit .cancelled; ignored due to finished=true
case .failed(let error):
let msg = Self.describeNWError(error)
dlog("tcpPing: FAILED \(host):\(port)\(msg)")
timeoutWork.cancel()
finishOnce(false, -1, msg, reason: "failed")
conn.cancel()
case .cancelled:
// If we already finished (e.g., due to .ready), this is expected; ignore.
if finished {
dlog("tcpPing: CANCELLED \(host):\(port) after finish — ignoring")
} else {
// Cancel without prior .ready/.failed implies timeout or external cancel
finishOnce(false, -1, "connection cancelled (possible timeout)", reason: "cancelled-before-finish")
}
default:
break
}
}
conn.start(queue: queue)
// Schedule timeout on the same queue
queue.asyncAfter(deadline: .now() + timeout, execute: timeoutWork)
}
// Tooltips
func tooltipForSvc() -> String {
guard let svc = Config.shared.primaryService else { return "No primary service configured" }
if svcReachable { return "\(svc.host):\(svc.port) — reachable (\(svcLatency) ms)" }
var s = "\(svc.host):\(svc.port) — unreachable"
if let e = svcError { s += "\nError: \(e)" }
return s
}
func tooltipForAggregateK3s() -> String {
let kubes = Config.shared.kubernetes
let rHost = kubes.first?.host ?? "-"
let pHost = kubes.count > 1 ? kubes[1].host : "-"
var r = raspberryReachable ? "\(rHost) ✓ (\(raspberryLatency) ms)" : "\(rHost)"
var p = piReachable ? "\(pHost) ✓ (\(piLatency) ms)" : "\(pHost)"
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 {
guard let local = Config.shared.localService else { return "No local service configured" }
if localReachable { return "k3d \(local.host):\(local.port) — reachable (\(localLatency) ms)" }
var s = "k3d \(local.host):\(local.port) — 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"
}
}
}