- InstanceEndpoint 取代 Instance.origin(旧数据 init(from:) 读成 .direct, 写盘只输出 endpoint;dedupKey 直通用 origin、中继用 relay:<nodeID>) - RelayWire:Swift 侧重算 contracts/test-vectors.json(CanonicalJSON 手写解析器, 因为 Foundation 的解码器会抹掉'拼写',而 intent 摘要定义的正是拼写) - NodeTransport 抽象 + DirectTransport / RelayTransport / NodeTransportRouter; DirectTransport 是薄适配,直通路径行为不变 - RelayTransport:信封构造、签名、加密、验签、解密;响应验签失败即丢弃, 四类以上失败各有独立 UpstreamError 分支与用户文案 - DeviceIdentity(沙盒文件 0600,配对时由节点分配 deviceID)、RelayPairing、配对 sheet - 卡片通道徽标 + freshness 副标题;危险写 intent 在发送瞬间生成 - scripts/export-app-routes.swift 生成 vendored 路由表,AppRoutesContractTests 反向守卫 - 测试 78→92:向量互算、迁移(字面量旧 JSON)、配对四类失败、路由器、 传输往返与各类拒绝路径
267 lines
9.7 KiB
Swift
267 lines
9.7 KiB
Swift
import Foundation
|
|
|
|
public struct LogCenterService: Sendable {
|
|
private struct ProbeOutcome: Sendable {
|
|
var status: ConnectionStatus
|
|
var outcome: ConnectionOutcome
|
|
var state: String
|
|
var errorCode: String?
|
|
var httpStatus: Int?
|
|
var durationMs: Int
|
|
var message: String
|
|
var version: String?
|
|
var platform: String?
|
|
}
|
|
|
|
private struct InstanceCollection: Sendable {
|
|
var runtime: [RuntimeLogEntry] = []
|
|
var connections: [ConnectionLogEntry] = []
|
|
var diagnostic: InstanceDiagnostic?
|
|
}
|
|
|
|
private let transport: any NodeTransport
|
|
private let store: any LogStore
|
|
|
|
public init(transport: any NodeTransport, store: any LogStore) {
|
|
self.transport = transport
|
|
self.store = store
|
|
}
|
|
|
|
/// Direct-mode construction, kept for the existing call sites and tests.
|
|
public init(
|
|
client: any SimAdminClientProtocol,
|
|
sessions: InstanceSessionStore,
|
|
credentials: (any CredentialStore)? = nil,
|
|
store: any LogStore
|
|
) {
|
|
self.init(
|
|
transport: DirectTransport(client: client, sessions: sessions, credentials: credentials),
|
|
store: store
|
|
)
|
|
}
|
|
|
|
public func refresh(_ instances: [Instance]) async throws -> LogSnapshot {
|
|
var collected: [InstanceCollection] = []
|
|
await withTaskGroup(of: InstanceCollection.self) { group in
|
|
for instance in instances {
|
|
group.addTask { await self.collect(instance) }
|
|
}
|
|
for await value in group {
|
|
collected.append(value)
|
|
}
|
|
}
|
|
|
|
collected.sort { ($0.diagnostic?.name ?? "") < ($1.diagnostic?.name ?? "") }
|
|
let runtime = collected.flatMap(\.runtime).sorted { $0.occurredAt > $1.occurredAt }
|
|
let connections = collected.flatMap(\.connections).sorted { $0.observedAt > $1.observedAt }
|
|
let diagnostics = collected.compactMap(\.diagnostic)
|
|
try await store.append(runtime: runtime, connections: connections)
|
|
|
|
var counts: [RuntimeLogSource: Int] = [:]
|
|
for source in RuntimeLogSource.allCases {
|
|
counts[source] = runtime.filter { $0.source == source }.count
|
|
}
|
|
return LogSnapshot(
|
|
observedAt: Date(),
|
|
runtime: runtime,
|
|
counts: counts,
|
|
connections: connections,
|
|
diagnostics: diagnostics
|
|
)
|
|
}
|
|
|
|
private func collect(_ instance: Instance) async -> InstanceCollection {
|
|
var result = InstanceCollection()
|
|
let probe = await probe(instance)
|
|
var modules: [String: Result<Bool, Error>] = [
|
|
"connection": .success(true),
|
|
"device": await moduleResult(instance, .device)
|
|
]
|
|
if probe.outcome == .failed {
|
|
modules["connection"] = .failure(UpstreamError.notSupported)
|
|
modules["device"] = .failure(UpstreamError.notSupported)
|
|
}
|
|
result.connections.append(ConnectionLogEntry(
|
|
id: "probe-\(instance.id.uuidString)-\(Int(Date().timeIntervalSince1970 * 1000))",
|
|
instanceID: instance.id,
|
|
instanceName: instance.name,
|
|
outcome: probe.outcome,
|
|
state: probe.state,
|
|
errorCode: probe.errorCode,
|
|
httpStatus: probe.httpStatus,
|
|
durationMs: probe.durationMs,
|
|
observedAt: Date()
|
|
))
|
|
if probe.outcome == .failed || probe.outcome == .stale {
|
|
result.runtime.append(RuntimeLogEntry(
|
|
id: "connection-\(instance.id.uuidString)-\(Int(Date().timeIntervalSince1970 * 1000))",
|
|
source: .delivery,
|
|
level: probe.outcome == .failed ? .error : .warning,
|
|
message: probe.message,
|
|
instanceID: instance.id,
|
|
instanceName: instance.name,
|
|
occurredAt: Date(),
|
|
code: probe.errorCode,
|
|
durationMs: probe.durationMs
|
|
))
|
|
}
|
|
|
|
modules["status"] = .success(true)
|
|
result.diagnostic = diagnostic(
|
|
instance,
|
|
probe: probe,
|
|
modules: modules,
|
|
connections: result.connections
|
|
)
|
|
return result
|
|
}
|
|
|
|
private func probe(_ instance: Instance) async -> ProbeOutcome {
|
|
let startedAt = Date()
|
|
do {
|
|
let response = try await transport.send(
|
|
.health,
|
|
instanceID: instance.id,
|
|
endpoint: instance.endpoint
|
|
)
|
|
let duration = elapsed(startedAt)
|
|
let root = (try? JSONSerialization.jsonObject(with: response.data)) as? [String: Any] ?? [:]
|
|
return ProbeOutcome(
|
|
status: .online,
|
|
outcome: .success,
|
|
state: "online",
|
|
errorCode: nil,
|
|
httpStatus: response.status,
|
|
durationMs: duration,
|
|
message: "节点连接成功",
|
|
version: Self.text(root["version"]),
|
|
platform: Self.text(root["platform"])
|
|
)
|
|
} catch UpstreamError.unauthorized, UpstreamError.forbidden {
|
|
return ProbeOutcome(
|
|
status: .authRequired,
|
|
outcome: .stale,
|
|
state: "auth_required",
|
|
errorCode: "UNAUTHORIZED",
|
|
httpStatus: 401,
|
|
durationMs: elapsed(startedAt),
|
|
message: "节点需要登录",
|
|
version: nil,
|
|
platform: nil
|
|
)
|
|
} catch UpstreamError.timeout {
|
|
return ProbeOutcome(
|
|
status: .offline,
|
|
outcome: .failed,
|
|
state: "unreachable",
|
|
errorCode: "TIMEOUT",
|
|
httpStatus: nil,
|
|
durationMs: elapsed(startedAt),
|
|
message: "节点连接超时",
|
|
version: nil,
|
|
platform: nil
|
|
)
|
|
} catch {
|
|
return ProbeOutcome(
|
|
status: .degraded,
|
|
outcome: .failed,
|
|
state: "unreachable",
|
|
errorCode: "TRANSPORT",
|
|
httpStatus: nil,
|
|
durationMs: elapsed(startedAt),
|
|
message: "节点连接失败",
|
|
version: nil,
|
|
platform: nil
|
|
)
|
|
}
|
|
}
|
|
|
|
private func moduleResult(
|
|
_ instance: Instance,
|
|
_ route: UpstreamRoute
|
|
) async -> Result<Bool, Error> {
|
|
do {
|
|
_ = try await request(route, instance)
|
|
return .success(true)
|
|
} catch {
|
|
return .failure(error)
|
|
}
|
|
}
|
|
|
|
private func diagnostic(
|
|
_ instance: Instance,
|
|
probe: ProbeOutcome,
|
|
modules: [String: Result<Bool, Error>],
|
|
connections: [ConnectionLogEntry]
|
|
) -> InstanceDiagnostic {
|
|
let featureStatus = modules
|
|
.sorted { $0.key < $1.key }
|
|
.map { key, result -> FeatureStatus in
|
|
let name = key == "connection" ? "连接" : key == "status" ? "状态" : "设备"
|
|
switch result {
|
|
case .success:
|
|
return FeatureStatus(name: name, state: .ready)
|
|
case .failure(UpstreamError.unauthorized), .failure(UpstreamError.forbidden):
|
|
return FeatureStatus(name: name, state: .requiresLogin)
|
|
case .failure:
|
|
return FeatureStatus(name: name, state: .unavailable)
|
|
}
|
|
}
|
|
let success = connections.filter { $0.outcome == .success }.count
|
|
let failed = connections.filter { $0.outcome == .failed }.count
|
|
let average = connections.isEmpty
|
|
? 0
|
|
: connections.map(\.durationMs).reduce(0, +) / connections.count
|
|
return InstanceDiagnostic(
|
|
id: instance.id,
|
|
name: instance.name,
|
|
origin: instance.endpoint.displayAddress,
|
|
tags: instance.tags,
|
|
state: probe.status,
|
|
healthStatus: Self.healthLabel(probe),
|
|
version: probe.version,
|
|
platform: probe.platform,
|
|
observedAt: Date(),
|
|
featureStatus: featureStatus,
|
|
connection: ConnectionSummary(
|
|
total: connections.count,
|
|
success: success,
|
|
failed: failed,
|
|
averageDurationMs: average,
|
|
availabilityPercent: connections.isEmpty ? 0 : success * 100 / connections.count
|
|
)
|
|
)
|
|
}
|
|
|
|
private func request(_ route: UpstreamRoute, _ instance: Instance) async throws -> UpstreamResponse {
|
|
try await transport.send(route, instanceID: instance.id, endpoint: instance.endpoint)
|
|
}
|
|
|
|
private func elapsed(_ startedAt: Date) -> Int {
|
|
max(0, Int(Date().timeIntervalSince(startedAt) * 1000))
|
|
}
|
|
|
|
private static func healthLabel(_ probe: ProbeOutcome) -> String {
|
|
switch probe.state {
|
|
case "online", "UP", "ok": return "在线"
|
|
case "auth_required": return "需登录"
|
|
case "unreachable": return probe.errorCode == "TIMEOUT" ? "超时" : "不可达"
|
|
default: return probe.state
|
|
}
|
|
}
|
|
|
|
private static func text(_ value: Any?) -> String? {
|
|
guard let value = value as? String, !value.isEmpty, value.count <= 512 else { return nil }
|
|
return value
|
|
}
|
|
|
|
private static func date(_ value: Any?) -> Date {
|
|
guard let text = value as? String else { return Date() }
|
|
let formatter = ISO8601DateFormatter()
|
|
formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
|
|
if let parsed = formatter.date(from: text) { return parsed }
|
|
formatter.formatOptions = [.withInternetDateTime]
|
|
return formatter.date(from: text) ?? Date()
|
|
}
|
|
}
|