141 lines
4.9 KiB
Swift
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)?
|
|
|
|
var 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"
|
|
}
|
|
}
|
|
}
|