import Foundation public protocol SimAdminClientProtocol: Sendable { func send(_ route: UpstreamRoute, baseURL: URL, cookie: String?) async throws -> UpstreamResponse } extension SimAdminClient: SimAdminClientProtocol {} public enum ConnectionStatus: String, Equatable, Sendable { case online case offline case authRequired case degraded case unknown public var label: String { switch self { case .online: return "在线" case .offline: return "离线" case .authRequired: return "需登录" case .degraded: return "降级" case .unknown: return "未知" } } } public struct ConnectionResult: Equatable, Sendable { public let status: ConnectionStatus public let requestLatencyMilliseconds: Int? public let checkedAt: Date /// Why the probe ended where it did. /// /// Kept alongside the status rather than folded into it because the status vocabulary is five /// values and the failure vocabulary is much larger: in relay mode "the relay is unreachable", /// "the agent is offline", "the envelope was refused" and "the node returned an error" are four /// different user actions, and a single `.offline` cannot say which one happened. public let error: UpstreamError? public init( status: ConnectionStatus, requestLatencyMilliseconds: Int? = nil, checkedAt: Date, error: UpstreamError? = nil ) { self.status = status self.requestLatencyMilliseconds = requestLatencyMilliseconds self.checkedAt = checkedAt self.error = error } /// How old this reading is, for the freshness vocabulary. A relay reading is inherently older /// than a direct one, and saying so is the difference between "online" meaning now and meaning /// forty seconds ago. public var freshness: SnapshotFreshness { SnapshotFreshness.classify(age: Date().timeIntervalSince(checkedAt)) } } public struct ConnectionProbe: Sendable { private let transport: any NodeTransport public init(transport: any NodeTransport) { self.transport = transport } /// Direct-mode construction, kept for the existing call sites and tests. public init(client: any SimAdminClientProtocol) { self.init(transport: DirectTransport(client: client, sessions: InstanceSessionStore())) } public func test(_ instance: Instance) async throws -> ConnectionResult { let startedAt = DispatchTime.now() do { let response = try await transport.send( .health, instanceID: instance.id, endpoint: instance.endpoint ) let latency = Self.elapsedMilliseconds(since: startedAt) if response.status == 401 || response.status == 403 { return ConnectionResult( status: .authRequired, requestLatencyMilliseconds: latency, checkedAt: Date() ) } return ConnectionResult( status: .online, requestLatencyMilliseconds: latency, checkedAt: Date() ) } catch { let latency = Self.elapsedMilliseconds(since: startedAt) let upstream = error as? UpstreamError return ConnectionResult( status: Self.status(for: upstream, error: error), requestLatencyMilliseconds: latency, checkedAt: Date(), error: upstream ) } } /// Maps a failure onto the five-value status vocabulary the list already uses, while the typed /// error travels alongside it for the detail view. /// /// The relay-mode cases are mapped rather than collapsed: an offline agent is a node problem the /// user waits out, a rejected envelope is a pairing problem that will never fix itself, and an /// unreachable relay is neither. static func status(for upstream: UpstreamError?, error: Error) -> ConnectionStatus { switch upstream { case .unauthorized: return .authRequired case .timeout, .relayTimeout, .relayUnreachable, .agentOffline, .agentTimeout, .nodeBusy: return .offline case .relayRejectedDevice, .relayNodeUnknown: // The pairing is gone: retrying cannot help, and the user has to pair again. return .offline case .relayPolicyDenied, .confirmationRejected, .envelopeRejected, .envelopeExpired, .relayResponseUnverifiable, .relayResponseMismatch, .relayBadEnvelope, .nodeAuthFailed, .payloadTooLarge: return .degraded case .relayRateLimited, .nodeRateLimited: return .degraded case .invalidRequest, .notSupported, .transportFailed, .invalidResponse, .forbidden: return .degraded case .none: if error is URLError { return .offline } return .degraded } } private static func elapsedMilliseconds(since startedAt: DispatchTime) -> Int? { let nanoseconds = DispatchTime.now().uptimeNanoseconds &- startedAt.uptimeNanoseconds let milliseconds = Double(nanoseconds) / 1_000_000 return milliseconds.isFinite ? Int(milliseconds.rounded()) : nil } }