PadXcode-iPad/Network/DaemonHealthMonitor.swift
2026-04-12 00:46:30 -07:00

141 lines
4.9 KiB
Swift

import Foundation
import SwiftUI
enum DaemonConnectionState: Equatable {
case unknown
case connecting
case connected(latencyMs: Int)
case disconnected(reason: String)
case reconnecting(attempt: Int)
}
@MainActor
final class DaemonHealthMonitor: ObservableObject {
@Published var state: DaemonConnectionState = .unknown
@Published var consecutiveFailures = 0
var onReconnected: (() async -> Void)?
var onDisconnected: (() -> Void)?
private let config: DaemonConfiguration
private var pollTask: Task<Void, Never>?
private let pollInterval: TimeInterval = 8
private let timeout: TimeInterval = 4
init(config: DaemonConfiguration) { self.config = config }
func startPolling() {
pollTask?.cancel()
pollTask = Task { [weak self] in
while !Task.isCancelled {
await self?.poll()
try? await Task.sleep(for: .seconds(self?.pollInterval ?? 8))
}
}
}
func stopPolling() { pollTask?.cancel(); pollTask = nil }
func reconnectNow() async { consecutiveFailures = 0; await poll() }
private func poll() async {
guard let url = URL(string: "\(config.baseURL)/health") else { return }
var req = URLRequest(url: url); req.timeoutInterval = timeout
let start = Date()
do {
let wasDown: Bool
if case .disconnected = state { wasDown = true }
else if case .reconnecting = state { wasDown = true }
else { wasDown = false }
state = consecutiveFailures > 0 ? .reconnecting(attempt: consecutiveFailures) : .connecting
let (_, resp) = try await URLSession.shared.data(for: req)
guard (resp as? HTTPURLResponse)?.statusCode == 200 else {
handleFailure("Bad status"); return
}
let ms = Int(Date().timeIntervalSince(start) * 1000)
consecutiveFailures = 0
state = .connected(latencyMs: ms)
if wasDown { await onReconnected?() }
} catch {
handleFailure(error.localizedDescription)
}
}
private func handleFailure(_ reason: String) {
consecutiveFailures += 1
state = .disconnected(reason: reason)
onDisconnected?()
}
}
// MARK: - Status Indicator View
struct DaemonStatusIndicator: View {
@ObservedObject var monitor: DaemonHealthMonitor
@State private var showDetail = false
var body: some View {
Button { showDetail.toggle() } label: {
HStack(spacing: 4) {
Circle().fill(indicatorColor).frame(width: 8, height: 8)
Text(shortLabel)
.font(.system(.caption2, design: .monospaced))
.foregroundStyle(.secondary)
}
}
.buttonStyle(.plain)
.popover(isPresented: $showDetail) {
VStack(alignment: .leading, spacing: 8) {
Label("Mac Daemon", systemImage: "desktopcomputer").font(.headline)
Divider()
HStack { Text("Status").foregroundStyle(.secondary); Spacer()
Text(monitor.state.statusLabel).bold() }
if case .connected(let ms) = monitor.state {
HStack { Text("Latency").foregroundStyle(.secondary); Spacer()
Text("\(ms) ms").bold() }
}
if monitor.consecutiveFailures > 0 {
HStack { Text("Failures").foregroundStyle(.secondary); Spacer()
Text("\(monitor.consecutiveFailures)").foregroundStyle(.red).bold() }
}
if case .disconnected = monitor.state {
Button("Reconnect Now") { Task { await monitor.reconnectNow() } }
.buttonStyle(.borderedProminent).controlSize(.small).padding(.top, 4)
}
}
.padding(16).frame(width: 220)
}
}
private var indicatorColor: Color {
switch monitor.state {
case .connected: return .green
case .connecting: return .yellow
case .reconnecting: return .orange
case .disconnected: return .red
default: return .secondary
}
}
private var shortLabel: String {
switch monitor.state {
case .connected(let ms): return "\(ms)ms"
case .connecting: return ""
case .reconnecting(let n): return "retry \(n)"
case .disconnected: return "offline"
default: return ""
}
}
}
extension DaemonConnectionState {
var statusLabel: String {
switch self {
case .connected: return "Connected"
case .connecting: return "Connecting…"
case .reconnecting(let n): return "Reconnecting (\(n))"
case .disconnected(let r): return "Disconnected: \(r)"
case .unknown: return "Unknown"
}
}
}