Files
multi-simadmin/apps/ios/Sources/MultiSimAdmin/Features/Logs/LogCenterService.swift
T
chick e01a105645 feat(ios): simplify navigation and ship 0.2.0
Move diagnostics into settings, remove the app-local notification module, unify the global theme switcher, refine privacy copy and the instance detail experience, and rebuild the app icon.
2026-09-12 17:10:02 +08:00

282 lines
10 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 client: any SimAdminClientProtocol
private let sessions: InstanceSessionStore
private let credentials: (any CredentialStore)?
private let store: any LogStore
public init(
client: any SimAdminClientProtocol,
sessions: InstanceSessionStore,
credentials: (any CredentialStore)? = nil,
store: any LogStore
) {
self.client = client
self.sessions = sessions
self.credentials = credentials
self.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 client.send(.health, baseURL: instance.origin, cookie: nil)
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.origin.absoluteString,
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 {
var cookie = await sessions.cookie(for: instance.id, origin: instance.origin)
do {
return try await client.send(route, baseURL: instance.origin, cookie: cookie)
} catch UpstreamError.unauthorized, UpstreamError.forbidden {
cookie = try await login(instance)
return try await client.send(route, baseURL: instance.origin, cookie: cookie)
}
}
private func login(_ instance: Instance) async throws -> String {
guard let credentials,
let password = try credentials.get(instanceID: instance.id),
!password.isEmpty
else {
throw UpstreamError.unauthorized
}
let service = InstanceSessionService(store: sessions, client: client)
let result = try await service.login(instance, password: password)
guard result.authenticated,
let cookie = await sessions.cookie(for: instance.id, origin: instance.origin)
else {
throw UpstreamError.unauthorized
}
return cookie
}
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()
}
}