feat(ios): add cross-instance log center
This commit is contained in:
@@ -0,0 +1,200 @@
|
||||
import Foundation
|
||||
|
||||
public enum RuntimeLogSource: String, Codable, Equatable, Sendable, CaseIterable {
|
||||
case delivery
|
||||
|
||||
public var label: String {
|
||||
switch self {
|
||||
case .delivery: return "通知投递"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public enum RuntimeLogLevel: String, Codable, Equatable, Sendable, CaseIterable {
|
||||
case info
|
||||
case warning
|
||||
case error
|
||||
|
||||
public var label: String {
|
||||
switch self {
|
||||
case .info: return "信息"
|
||||
case .warning: return "警告"
|
||||
case .error: return "错误"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public enum ConnectionOutcome: String, Codable, Equatable, Sendable, CaseIterable {
|
||||
case success
|
||||
case stale
|
||||
case failed
|
||||
case unsupported
|
||||
|
||||
public var label: String {
|
||||
switch self {
|
||||
case .success: return "成功"
|
||||
case .stale: return "需登录"
|
||||
case .failed: return "失败"
|
||||
case .unsupported: return "不支持"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public struct RuntimeLogEntry: Codable, Equatable, Identifiable, Sendable {
|
||||
public let id: String
|
||||
public let source: RuntimeLogSource
|
||||
public let level: RuntimeLogLevel
|
||||
public let message: String
|
||||
public let eventType: String?
|
||||
public let instanceID: UUID?
|
||||
public let instanceName: String?
|
||||
public let occurredAt: Date
|
||||
public let code: String?
|
||||
public let durationMs: Int?
|
||||
|
||||
public init(
|
||||
id: String,
|
||||
source: RuntimeLogSource,
|
||||
level: RuntimeLogLevel,
|
||||
message: String,
|
||||
eventType: String? = nil,
|
||||
instanceID: UUID? = nil,
|
||||
instanceName: String? = nil,
|
||||
occurredAt: Date,
|
||||
code: String? = nil,
|
||||
durationMs: Int? = nil
|
||||
) {
|
||||
self.id = id
|
||||
self.source = source
|
||||
self.level = level
|
||||
self.message = message
|
||||
self.eventType = eventType
|
||||
self.instanceID = instanceID
|
||||
self.instanceName = instanceName
|
||||
self.occurredAt = occurredAt
|
||||
self.code = code
|
||||
self.durationMs = durationMs
|
||||
}
|
||||
}
|
||||
|
||||
public struct ConnectionLogEntry: Codable, Equatable, Identifiable, Sendable {
|
||||
public let id: String
|
||||
public let instanceID: UUID
|
||||
public let instanceName: String
|
||||
public let outcome: ConnectionOutcome
|
||||
public let state: String
|
||||
public let errorCode: String?
|
||||
public let httpStatus: Int?
|
||||
public let durationMs: Int
|
||||
public let observedAt: Date
|
||||
|
||||
public init(
|
||||
id: String,
|
||||
instanceID: UUID,
|
||||
instanceName: String,
|
||||
outcome: ConnectionOutcome,
|
||||
state: String,
|
||||
errorCode: String? = nil,
|
||||
httpStatus: Int? = nil,
|
||||
durationMs: Int,
|
||||
observedAt: Date
|
||||
) {
|
||||
self.id = id
|
||||
self.instanceID = instanceID
|
||||
self.instanceName = instanceName
|
||||
self.outcome = outcome
|
||||
self.state = state
|
||||
self.errorCode = errorCode
|
||||
self.httpStatus = httpStatus
|
||||
self.durationMs = durationMs
|
||||
self.observedAt = observedAt
|
||||
}
|
||||
}
|
||||
|
||||
public struct LogCapabilitySummary: Equatable, Sendable {
|
||||
public let total: Int
|
||||
public let supported: Int
|
||||
public let unsupported: Int
|
||||
public let authRequired: Int
|
||||
public let degraded: Int
|
||||
public let unknown: Int
|
||||
|
||||
public init(
|
||||
total: Int,
|
||||
supported: Int,
|
||||
unsupported: Int,
|
||||
authRequired: Int,
|
||||
degraded: Int,
|
||||
unknown: Int
|
||||
) {
|
||||
self.total = total
|
||||
self.supported = supported
|
||||
self.unsupported = unsupported
|
||||
self.authRequired = authRequired
|
||||
self.degraded = degraded
|
||||
self.unknown = unknown
|
||||
}
|
||||
}
|
||||
|
||||
public struct ConnectionSummary: Equatable, Sendable {
|
||||
public let total: Int
|
||||
public let success: Int
|
||||
public let failed: Int
|
||||
public let averageDurationMs: Int
|
||||
public let availabilityPercent: Int
|
||||
}
|
||||
|
||||
public struct InstanceDiagnostic: Equatable, Identifiable, Sendable {
|
||||
public let id: UUID
|
||||
public let name: String
|
||||
public let origin: String
|
||||
public let tags: [String]
|
||||
public let state: ConnectionStatus
|
||||
public let healthStatus: String?
|
||||
public let version: String?
|
||||
public let platform: String?
|
||||
public let observedAt: Date
|
||||
public let capabilities: LogCapabilitySummary
|
||||
public let connection: ConnectionSummary?
|
||||
|
||||
public init(
|
||||
id: UUID,
|
||||
name: String,
|
||||
origin: String,
|
||||
tags: [String],
|
||||
state: ConnectionStatus,
|
||||
healthStatus: String?,
|
||||
version: String?,
|
||||
platform: String?,
|
||||
observedAt: Date,
|
||||
capabilities: LogCapabilitySummary,
|
||||
connection: ConnectionSummary?
|
||||
) {
|
||||
self.id = id
|
||||
self.name = name
|
||||
self.origin = origin
|
||||
self.tags = tags
|
||||
self.state = state
|
||||
self.healthStatus = healthStatus
|
||||
self.version = version
|
||||
self.platform = platform
|
||||
self.observedAt = observedAt
|
||||
self.capabilities = capabilities
|
||||
self.connection = connection
|
||||
}
|
||||
}
|
||||
|
||||
public struct LogSnapshot: Equatable, Sendable {
|
||||
public let observedAt: Date
|
||||
public let runtime: [RuntimeLogEntry]
|
||||
public let counts: [RuntimeLogSource: Int]
|
||||
public let connections: [ConnectionLogEntry]
|
||||
public let diagnostics: [InstanceDiagnostic]
|
||||
}
|
||||
|
||||
public protocol LogStore: Sendable {
|
||||
func runtimeLogs() async throws -> [RuntimeLogEntry]
|
||||
func connectionLogs() async throws -> [ConnectionLogEntry]
|
||||
func append(runtime: [RuntimeLogEntry], connections: [ConnectionLogEntry]) async throws
|
||||
func pruneConnections(before: Date) async throws -> Int
|
||||
}
|
||||
@@ -0,0 +1,372 @@
|
||||
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
|
||||
))
|
||||
}
|
||||
|
||||
let deliveryEntries = probe.outcome == .failed ? [] : await deliveryLogs(instance)
|
||||
result.runtime.append(contentsOf: deliveryEntries)
|
||||
if probe.outcome == .failed {
|
||||
modules["delivery"] = .failure(UpstreamError.notSupported)
|
||||
} else if deliveryEntries.contains(where: { $0.code == "UNAUTHORIZED" }) {
|
||||
modules["delivery"] = .failure(UpstreamError.unauthorized)
|
||||
} else if deliveryEntries.contains(where: { $0.code == "UPSTREAM" }) {
|
||||
modules["delivery"] = .failure(UpstreamError.invalidResponse)
|
||||
} else {
|
||||
modules["delivery"] = .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 deliveryLogs(_ instance: Instance) async -> [RuntimeLogEntry] {
|
||||
do {
|
||||
let response = try await request(.notificationLogs(limit: 50, offset: 0), instance)
|
||||
let entries = Self.deliveryEntries(response, instance)
|
||||
if entries.isEmpty {
|
||||
throw UpstreamError.invalidResponse
|
||||
}
|
||||
return entries
|
||||
} catch UpstreamError.unauthorized, UpstreamError.forbidden {
|
||||
return [RuntimeLogEntry(
|
||||
id: "delivery-\(instance.id.uuidString)-auth",
|
||||
source: .delivery,
|
||||
level: .warning,
|
||||
message: "通知日志未同步:需要登录",
|
||||
instanceID: instance.id,
|
||||
instanceName: instance.name,
|
||||
occurredAt: Date(),
|
||||
code: "UNAUTHORIZED",
|
||||
durationMs: nil
|
||||
)]
|
||||
} catch {
|
||||
return [RuntimeLogEntry(
|
||||
id: "delivery-\(instance.id.uuidString)-failed",
|
||||
source: .delivery,
|
||||
level: .warning,
|
||||
message: "通知日志未同步:接口异常",
|
||||
instanceID: instance.id,
|
||||
instanceName: instance.name,
|
||||
occurredAt: Date(),
|
||||
code: "UPSTREAM",
|
||||
durationMs: nil
|
||||
)]
|
||||
}
|
||||
}
|
||||
|
||||
private func diagnostic(
|
||||
_ instance: Instance,
|
||||
probe: ProbeOutcome,
|
||||
modules: [String: Result<Bool, Error>],
|
||||
connections: [ConnectionLogEntry]
|
||||
) -> InstanceDiagnostic {
|
||||
let capabilities = LogCapabilitySummary(
|
||||
total: modules.count,
|
||||
supported: modules.values.filter {
|
||||
if case .success = $0 { return true }
|
||||
return false
|
||||
}.count,
|
||||
unsupported: modules.values.filter {
|
||||
if case .failure(UpstreamError.notSupported) = $0 { return true }
|
||||
return false
|
||||
}.count,
|
||||
authRequired: modules.values.filter {
|
||||
if case .failure(UpstreamError.unauthorized) = $0 { return true }
|
||||
if case .failure(UpstreamError.forbidden) = $0 { return true }
|
||||
return false
|
||||
}.count,
|
||||
degraded: modules.values.filter {
|
||||
if case .failure = $0 {
|
||||
if case .failure(UpstreamError.notSupported) = $0 { return false }
|
||||
if case .failure(UpstreamError.unauthorized) = $0 { return false }
|
||||
if case .failure(UpstreamError.forbidden) = $0 { return false }
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}.count,
|
||||
unknown: 0
|
||||
)
|
||||
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(),
|
||||
capabilities: capabilities,
|
||||
connection: ConnectionSummary(
|
||||
total: connections.count,
|
||||
success: success,
|
||||
failed: failed,
|
||||
averageDurationMs: average,
|
||||
availabilityPercent: connections.isEmpty ? 0 : success * 100 / connections.count
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
static func deliveryEntries(
|
||||
_ response: UpstreamResponse,
|
||||
_ instance: Instance
|
||||
) -> [RuntimeLogEntry] {
|
||||
guard let root = try? JSONSerialization.jsonObject(with: response.data) as? [String: Any],
|
||||
let data = root["data"] as? [String: Any],
|
||||
let raw = data["logs"] as? [[String: Any]]
|
||||
else {
|
||||
return []
|
||||
}
|
||||
return raw.compactMap { item in
|
||||
guard let id = Self.text(item["id"]),
|
||||
let status = Self.text(item["status"]),
|
||||
let eventType = Self.text(item["event_type"])
|
||||
else {
|
||||
return nil
|
||||
}
|
||||
let level: RuntimeLogLevel = status == "failed" ? .error : .info
|
||||
return RuntimeLogEntry(
|
||||
id: "delivery-\(instance.id.uuidString)-\(id)",
|
||||
source: .delivery,
|
||||
level: level,
|
||||
message: Self.text(item["message"]) ?? "\(eventType) · \(status)",
|
||||
eventType: eventType,
|
||||
instanceID: instance.id,
|
||||
instanceName: instance.name,
|
||||
occurredAt: Self.date(item["created_at"]),
|
||||
code: status,
|
||||
durationMs: nil
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
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()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,301 @@
|
||||
import SwiftUI
|
||||
|
||||
struct LogsView: View {
|
||||
private enum Tab: Hashable {
|
||||
case runtime
|
||||
case connections
|
||||
case diagnostics
|
||||
}
|
||||
|
||||
@StateObject private var model: LogsViewModel
|
||||
@State private var tab: Tab = .runtime
|
||||
|
||||
init(model: LogsViewModel? = nil) {
|
||||
_model = StateObject(wrappedValue: model ?? DefaultContainer.makeLogsModel())
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
AITheme.canvas.ignoresSafeArea()
|
||||
ScrollView {
|
||||
LazyVStack(spacing: 12) {
|
||||
if let errorMessage = model.errorMessage {
|
||||
AICard {
|
||||
Text(errorMessage)
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(AITheme.warning)
|
||||
}
|
||||
}
|
||||
stats
|
||||
Picker("视图", selection: $tab) {
|
||||
Text("运行日志").tag(Tab.runtime)
|
||||
Text("连接日志").tag(Tab.connections)
|
||||
Text("设备诊断").tag(Tab.diagnostics)
|
||||
}
|
||||
.pickerStyle(.segmented)
|
||||
|
||||
switch tab {
|
||||
case .runtime:
|
||||
runtimeSection
|
||||
case .connections:
|
||||
connectionSection
|
||||
case .diagnostics:
|
||||
diagnosticSection
|
||||
}
|
||||
}
|
||||
.padding(16)
|
||||
}
|
||||
}
|
||||
.navigationTitle("日志中心")
|
||||
.toolbar {
|
||||
ToolbarItemGroup(placement: .primaryAction) {
|
||||
Button {
|
||||
Task { await model.refresh() }
|
||||
} label: {
|
||||
Label("刷新", systemImage: "arrow.clockwise")
|
||||
}
|
||||
.buttonStyle(AIButtonStyle(kind: .primary, size: .regular))
|
||||
.disabled(model.isLoading)
|
||||
}
|
||||
}
|
||||
.refreshable {
|
||||
await model.refresh()
|
||||
}
|
||||
.task {
|
||||
await model.refresh()
|
||||
}
|
||||
}
|
||||
|
||||
private var stats: some View {
|
||||
AICard {
|
||||
VStack(spacing: 12) {
|
||||
filterBar
|
||||
HStack(spacing: 8) {
|
||||
metric("日志", "\(model.snapshot?.runtime.count ?? 0)", AITheme.primary)
|
||||
metric("在线", "\(onlineCount)", AITheme.success)
|
||||
metric("离线", "\(model.offlineCount)", AITheme.danger)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var filterBar: some View {
|
||||
VStack(spacing: 10) {
|
||||
TextField("搜索日志、错误码或实例", text: $model.searchText)
|
||||
.textFieldStyle(.plain)
|
||||
.padding(.horizontal, 12)
|
||||
.frame(minHeight: AITheme.buttonHeight)
|
||||
.background(AITheme.canvas)
|
||||
.clipShape(RoundedRectangle(cornerRadius: AITheme.buttonRadius))
|
||||
.overlay {
|
||||
RoundedRectangle(cornerRadius: AITheme.buttonRadius)
|
||||
.strokeBorder(AITheme.line)
|
||||
}
|
||||
|
||||
Picker("实例", selection: $model.selectedInstanceID) {
|
||||
Text("全部实例").tag(Optional<UUID>.none)
|
||||
ForEach(model.instances) { instance in
|
||||
Text(instance.name).tag(Optional(instance.id))
|
||||
}
|
||||
}
|
||||
.pickerStyle(.menu)
|
||||
|
||||
if tab == .runtime {
|
||||
Picker("来源", selection: $model.selectedSource) {
|
||||
Text("全部来源").tag(Optional<RuntimeLogSource>.none)
|
||||
ForEach(RuntimeLogSource.allCases, id: \.self) { source in
|
||||
Text(source.label).tag(Optional(source))
|
||||
}
|
||||
}
|
||||
.pickerStyle(.menu)
|
||||
|
||||
Picker("级别", selection: $model.selectedLevel) {
|
||||
Text("全部级别").tag(Optional<RuntimeLogLevel>.none)
|
||||
ForEach(RuntimeLogLevel.allCases, id: \.self) { level in
|
||||
Text(level.label).tag(Optional(level))
|
||||
}
|
||||
}
|
||||
.pickerStyle(.menu)
|
||||
}
|
||||
|
||||
if tab == .connections {
|
||||
Picker("结果", selection: $model.selectedOutcome) {
|
||||
Text("全部结果").tag(Optional<ConnectionOutcome>.none)
|
||||
ForEach(ConnectionOutcome.allCases, id: \.self) { outcome in
|
||||
Text(outcome.label).tag(Optional(outcome))
|
||||
}
|
||||
}
|
||||
.pickerStyle(.menu)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var runtimeSection: some View {
|
||||
AICard {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
sectionTitle("统一时间线", detail: "\(model.filteredRuntime.count) 条")
|
||||
if model.filteredRuntime.isEmpty {
|
||||
empty("暂无运行日志。")
|
||||
} else {
|
||||
ForEach(model.filteredRuntime) { entry in
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
HStack(spacing: 8) {
|
||||
Text(entry.message)
|
||||
.font(.subheadline.weight(.semibold))
|
||||
Spacer()
|
||||
AITag(entry.level.label, tint: levelTint(entry.level))
|
||||
}
|
||||
Text("\(entry.instanceName ?? "未知实例") · \(entry.occurredAt.formatted())")
|
||||
.font(.caption)
|
||||
.foregroundStyle(AITheme.muted)
|
||||
HStack(spacing: 6) {
|
||||
AITag(entry.source.label, tint: AITheme.primary)
|
||||
if let duration = entry.durationMs {
|
||||
AITag("\(duration) ms", tint: AITheme.muted)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.bottom, 6)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var connectionSection: some View {
|
||||
AICard {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
HStack {
|
||||
sectionTitle("连接探测记录", detail: "\(model.filteredConnections.count) 条")
|
||||
Button {
|
||||
Task {
|
||||
_ = await model.pruneConnections(olderThanDays: 7)
|
||||
}
|
||||
} label: {
|
||||
Label("清理", systemImage: "trash")
|
||||
}
|
||||
.buttonStyle(AIButtonStyle(kind: .secondary, size: .compact))
|
||||
}
|
||||
if model.filteredConnections.isEmpty {
|
||||
empty("暂无连接日志。")
|
||||
} else {
|
||||
ForEach(model.filteredConnections) { entry in
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
HStack(spacing: 8) {
|
||||
Text(entry.instanceName)
|
||||
.font(.subheadline.weight(.semibold))
|
||||
Spacer()
|
||||
AITag(entry.outcome.label, tint: outcomeTint(entry.outcome))
|
||||
}
|
||||
Text("\(entry.observedAt.formatted()) · \(entry.state)")
|
||||
.font(.caption)
|
||||
.foregroundStyle(AITheme.muted)
|
||||
Text("HTTP \(entry.httpStatus.map(String.init) ?? "--") · \(entry.durationMs) ms · \(entry.errorCode ?? "--")")
|
||||
.font(.caption)
|
||||
.foregroundStyle(AITheme.muted)
|
||||
}
|
||||
.padding(.bottom, 6)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var diagnosticSection: some View {
|
||||
AICard {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
sectionTitle("设备诊断", detail: "\(model.filteredDiagnostics.count) 台")
|
||||
if model.filteredDiagnostics.isEmpty {
|
||||
empty("暂无可诊断实例。")
|
||||
} else {
|
||||
ForEach(model.filteredDiagnostics) { diagnostic in
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
HStack(spacing: 8) {
|
||||
Text(diagnostic.name)
|
||||
.font(.subheadline.weight(.semibold))
|
||||
Spacer()
|
||||
AITag(
|
||||
diagnostic.healthStatus ?? "未知",
|
||||
tint: diagnostic.state == .online ? AITheme.success : AITheme.warning
|
||||
)
|
||||
}
|
||||
Text(diagnostic.origin)
|
||||
.font(.caption)
|
||||
.foregroundStyle(AITheme.muted)
|
||||
if let version = diagnostic.version {
|
||||
Text("版本 \(version) · \(diagnostic.platform ?? "未知平台")")
|
||||
.font(.caption)
|
||||
.foregroundStyle(AITheme.muted)
|
||||
}
|
||||
HStack(spacing: 6) {
|
||||
AITag(
|
||||
"能力 \(diagnostic.capabilities.supported)/\(diagnostic.capabilities.total)",
|
||||
tint: AITheme.primary
|
||||
)
|
||||
if let connection = diagnostic.connection {
|
||||
AITag(
|
||||
"可用 \(connection.availabilityPercent)%",
|
||||
tint: connection.failed == 0 ? AITheme.success : AITheme.warning
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.bottom, 8)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var onlineCount: Int {
|
||||
(model.snapshot?.diagnostics.filter { $0.state == .online }.count) ?? 0
|
||||
}
|
||||
|
||||
private func metric(_ title: String, _ value: String, _ tint: Color) -> some View {
|
||||
VStack(spacing: 4) {
|
||||
Text(title)
|
||||
.font(.caption)
|
||||
.foregroundStyle(AITheme.muted)
|
||||
Text(value)
|
||||
.font(.headline.weight(.bold))
|
||||
.foregroundStyle(tint)
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
|
||||
private func sectionTitle(_ title: String, detail: String) -> some View {
|
||||
HStack {
|
||||
Text(title)
|
||||
.font(.headline)
|
||||
Spacer()
|
||||
Text(detail)
|
||||
.font(.caption)
|
||||
.foregroundStyle(AITheme.muted)
|
||||
}
|
||||
}
|
||||
|
||||
private func empty(_ text: String) -> some View {
|
||||
Text(text)
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(AITheme.muted)
|
||||
.frame(maxWidth: .infinity, alignment: .center)
|
||||
.padding(.vertical, 18)
|
||||
}
|
||||
|
||||
private func levelTint(_ level: RuntimeLogLevel) -> Color {
|
||||
switch level {
|
||||
case .info: return AITheme.success
|
||||
case .warning: return AITheme.warning
|
||||
case .error: return AITheme.danger
|
||||
}
|
||||
}
|
||||
|
||||
private func outcomeTint(_ outcome: ConnectionOutcome) -> Color {
|
||||
switch outcome {
|
||||
case .success: return AITheme.success
|
||||
case .stale: return AITheme.warning
|
||||
case .failed: return AITheme.danger
|
||||
case .unsupported: return AITheme.muted
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
import Foundation
|
||||
|
||||
@MainActor
|
||||
final class LogsViewModel: ObservableObject {
|
||||
@Published private(set) var snapshot: LogSnapshot?
|
||||
@Published private(set) var instances: [Instance] = []
|
||||
@Published private(set) var isLoading = false
|
||||
@Published var errorMessage: String?
|
||||
|
||||
@Published var selectedSource: RuntimeLogSource?
|
||||
@Published var selectedLevel: RuntimeLogLevel?
|
||||
@Published var selectedOutcome: ConnectionOutcome?
|
||||
@Published var selectedInstanceID: UUID?
|
||||
@Published var searchText = ""
|
||||
|
||||
private let repository: any InstanceRepository
|
||||
private let service: LogCenterService
|
||||
private let store: any LogStore
|
||||
|
||||
init(
|
||||
repository: any InstanceRepository,
|
||||
service: LogCenterService,
|
||||
store: any LogStore
|
||||
) {
|
||||
self.repository = repository
|
||||
self.service = service
|
||||
self.store = store
|
||||
}
|
||||
|
||||
var filteredRuntime: [RuntimeLogEntry] {
|
||||
guard let snapshot else { return [] }
|
||||
let query = searchText.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
|
||||
return snapshot.runtime.filter { entry in
|
||||
(selectedSource == nil || entry.source == selectedSource)
|
||||
&& (selectedLevel == nil || entry.level == selectedLevel)
|
||||
&& (selectedInstanceID == nil || entry.instanceID == selectedInstanceID)
|
||||
&& (query.isEmpty || matches(entry.message, query)
|
||||
|| matches(entry.code, query)
|
||||
|| matches(entry.eventType, query)
|
||||
|| matches(entry.instanceName, query))
|
||||
}
|
||||
}
|
||||
|
||||
var filteredConnections: [ConnectionLogEntry] {
|
||||
guard let snapshot else { return [] }
|
||||
let query = searchText.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
|
||||
return snapshot.connections.filter { entry in
|
||||
(selectedOutcome == nil || entry.outcome == selectedOutcome)
|
||||
&& (selectedInstanceID == nil || entry.instanceID == selectedInstanceID)
|
||||
&& (query.isEmpty || matches(entry.errorCode, query)
|
||||
|| matches(entry.instanceName, query))
|
||||
}
|
||||
}
|
||||
|
||||
var filteredDiagnostics: [InstanceDiagnostic] {
|
||||
guard let snapshot else { return [] }
|
||||
let query = searchText.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
|
||||
guard !query.isEmpty else { return snapshot.diagnostics }
|
||||
return snapshot.diagnostics.filter { diagnostic in
|
||||
matches(diagnostic.name, query)
|
||||
|| matches(diagnostic.origin, query)
|
||||
|| diagnostic.tags.contains { matches($0, query) }
|
||||
}
|
||||
}
|
||||
|
||||
var offlineCount: Int {
|
||||
snapshot?.diagnostics.filter { $0.state == .offline }.count ?? 0
|
||||
}
|
||||
|
||||
var degradedCount: Int {
|
||||
snapshot?.diagnostics.filter { $0.capabilities.degraded > 0 }.count ?? 0
|
||||
}
|
||||
|
||||
func refresh() async {
|
||||
isLoading = true
|
||||
errorMessage = nil
|
||||
do {
|
||||
let loaded = try await repository.list(InstanceQuery())
|
||||
instances = loaded
|
||||
let fetched = try await service.refresh(loaded)
|
||||
let storedRuntime = try await store.runtimeLogs()
|
||||
let storedConnections = try await store.connectionLogs()
|
||||
var runtime = fetched.runtime
|
||||
for entry in storedRuntime where !runtime.contains(where: { $0.id == entry.id }) {
|
||||
runtime.append(entry)
|
||||
}
|
||||
runtime.sort { $0.occurredAt > $1.occurredAt }
|
||||
|
||||
var connections = fetched.connections
|
||||
for entry in storedConnections where !connections.contains(where: { $0.id == entry.id }) {
|
||||
connections.append(entry)
|
||||
}
|
||||
connections.sort { $0.observedAt > $1.observedAt }
|
||||
snapshot = LogSnapshot(
|
||||
observedAt: fetched.observedAt,
|
||||
runtime: Array(runtime.prefix(500)),
|
||||
counts: Self.recount(runtime),
|
||||
connections: Array(connections.prefix(200)),
|
||||
diagnostics: fetched.diagnostics
|
||||
)
|
||||
} catch {
|
||||
errorMessage = "无法加载日志中心。"
|
||||
}
|
||||
isLoading = false
|
||||
}
|
||||
|
||||
func pruneConnections(olderThanDays days: Int) async -> Int {
|
||||
let cutoff = Date().addingTimeInterval(TimeInterval(-days * 24 * 60 * 60))
|
||||
do {
|
||||
let removed = try await store.pruneConnections(before: cutoff)
|
||||
if removed > 0 {
|
||||
await refresh()
|
||||
}
|
||||
return removed
|
||||
} catch {
|
||||
errorMessage = "清理连接日志失败。"
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
private func matches(_ value: String?, _ query: String) -> Bool {
|
||||
value?.localizedLowercase.contains(query) == true
|
||||
}
|
||||
|
||||
private static func recount(_ runtime: [RuntimeLogEntry]) -> [RuntimeLogSource: Int] {
|
||||
var counts: [RuntimeLogSource: Int] = [:]
|
||||
for source in RuntimeLogSource.allCases {
|
||||
counts[source] = runtime.filter { $0.source == source }.count
|
||||
}
|
||||
return counts
|
||||
}
|
||||
}
|
||||
@@ -17,7 +17,7 @@ struct MultiSimAdminApp: App {
|
||||
.tabItem {
|
||||
Label("通知", systemImage: "bell")
|
||||
}
|
||||
Text("日志")
|
||||
LogsView()
|
||||
.tabItem {
|
||||
Label("日志", systemImage: "doc.text")
|
||||
}
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
import Foundation
|
||||
|
||||
public struct FileLogStore: LogStore {
|
||||
private struct Store: Codable {
|
||||
var runtime: [RuntimeLogEntry] = []
|
||||
var connections: [ConnectionLogEntry] = []
|
||||
}
|
||||
|
||||
private let location: URL
|
||||
private let encoder: JSONEncoder
|
||||
private let decoder: JSONDecoder
|
||||
private let queue = DispatchQueue(label: "fun.chick.multi-simadmin.log-store")
|
||||
|
||||
public init(location: URL) {
|
||||
self.location = location
|
||||
let encoder = JSONEncoder()
|
||||
encoder.dateEncodingStrategy = .iso8601
|
||||
encoder.outputFormatting = [.sortedKeys]
|
||||
self.encoder = encoder
|
||||
|
||||
let decoder = JSONDecoder()
|
||||
decoder.dateDecodingStrategy = .iso8601
|
||||
self.decoder = decoder
|
||||
}
|
||||
|
||||
public func runtimeLogs() async throws -> [RuntimeLogEntry] {
|
||||
try await run { $0.runtime.sorted { $0.occurredAt > $1.occurredAt } }
|
||||
}
|
||||
|
||||
public func connectionLogs() async throws -> [ConnectionLogEntry] {
|
||||
try await run { $0.connections.sorted { $0.observedAt > $1.observedAt } }
|
||||
}
|
||||
|
||||
public func append(
|
||||
runtime: [RuntimeLogEntry],
|
||||
connections: [ConnectionLogEntry]
|
||||
) async throws {
|
||||
try await run { store in
|
||||
var mergedRuntime = store.runtime
|
||||
for entry in runtime where !mergedRuntime.contains(where: { $0.id == entry.id }) {
|
||||
mergedRuntime.append(entry)
|
||||
}
|
||||
mergedRuntime.sort { $0.occurredAt > $1.occurredAt }
|
||||
store.runtime = Array(mergedRuntime.prefix(2_000))
|
||||
|
||||
var mergedConnections = store.connections
|
||||
for entry in connections
|
||||
where !mergedConnections.contains(where: { $0.id == entry.id }) {
|
||||
mergedConnections.append(entry)
|
||||
}
|
||||
mergedConnections.sort { $0.observedAt > $1.observedAt }
|
||||
store.connections = Array(mergedConnections.prefix(20_000))
|
||||
}
|
||||
}
|
||||
|
||||
public func pruneConnections(before cutoff: Date) async throws -> Int {
|
||||
try await run { store in
|
||||
let retained = store.connections.filter { $0.observedAt >= cutoff }
|
||||
let removed = store.connections.count - retained.count
|
||||
store.connections = retained
|
||||
return removed
|
||||
}
|
||||
}
|
||||
|
||||
private func run<T: Sendable>(
|
||||
_ transform: @escaping @Sendable (inout Store) throws -> T
|
||||
) async throws -> T {
|
||||
try await withCheckedThrowingContinuation { continuation in
|
||||
queue.async {
|
||||
do {
|
||||
var store = try self.readStore()
|
||||
let result = try transform(&store)
|
||||
try self.writeStore(store)
|
||||
continuation.resume(returning: result)
|
||||
} catch {
|
||||
continuation.resume(throwing: error)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func readStore() throws -> Store {
|
||||
guard FileManager.default.fileExists(atPath: location.path) else {
|
||||
return Store()
|
||||
}
|
||||
do {
|
||||
return try decoder.decode(Store.self, from: Data(contentsOf: location))
|
||||
} catch {
|
||||
return Store()
|
||||
}
|
||||
}
|
||||
|
||||
private func writeStore(_ store: Store) throws {
|
||||
do {
|
||||
try FileManager.default.createDirectory(
|
||||
at: location.deletingLastPathComponent(),
|
||||
withIntermediateDirectories: true
|
||||
)
|
||||
try encoder.encode(store).write(to: location, options: [.atomic])
|
||||
} catch {
|
||||
throw RepositoryError.databaseFailed
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -39,6 +39,21 @@ enum DefaultContainer {
|
||||
)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
static func makeLogsModel() -> LogsViewModel {
|
||||
let store = FileLogStore(location: documentFileURL("log-center.json"))
|
||||
return LogsViewModel(
|
||||
repository: FileInstanceRepository(location: documentFileURL("instances.json")),
|
||||
service: LogCenterService(
|
||||
client: SimAdminClient(),
|
||||
sessions: InstanceSessionStore(),
|
||||
credentials: KeychainCredentialStore(),
|
||||
store: store
|
||||
),
|
||||
store: store
|
||||
)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
static func makeDeviceModel(instance: Instance) -> DeviceViewModel {
|
||||
DeviceViewModel(
|
||||
|
||||
@@ -0,0 +1,219 @@
|
||||
import XCTest
|
||||
|
||||
@testable import MultiSimAdmin
|
||||
|
||||
final class LogCenterTests: XCTestCase {
|
||||
func testAggregatesDeliveryLogsConnectionsAndDiagnostics() async throws {
|
||||
let store = MemoryLogStore()
|
||||
let service = LogCenterService(
|
||||
client: LogCenterTestClient(),
|
||||
sessions: InstanceSessionStore(),
|
||||
store: store
|
||||
)
|
||||
let instance = Self.instance()
|
||||
|
||||
let snapshot = try await service.refresh([instance])
|
||||
|
||||
XCTAssertEqual(snapshot.runtime.count, 2)
|
||||
XCTAssertEqual(snapshot.runtime.first?.id, "delivery-\(instance.id.uuidString)-log-2")
|
||||
XCTAssertEqual(snapshot.runtime.first?.level, .error)
|
||||
XCTAssertEqual(snapshot.runtime.first?.instanceName, instance.name)
|
||||
XCTAssertEqual(snapshot.runtime.first?.source, .delivery)
|
||||
XCTAssertEqual(snapshot.counts[.delivery], 2)
|
||||
XCTAssertEqual(store.runtimeStorage.count, 2)
|
||||
|
||||
XCTAssertEqual(snapshot.connections.count, 1)
|
||||
XCTAssertEqual(snapshot.connections.first?.outcome, .success)
|
||||
XCTAssertEqual(snapshot.connections.first?.state, "online")
|
||||
XCTAssertEqual(store.connectionStorage.count, 1)
|
||||
|
||||
let diagnostic = try XCTUnwrap(snapshot.diagnostics.first)
|
||||
XCTAssertEqual(diagnostic.id, instance.id)
|
||||
XCTAssertEqual(diagnostic.state, .online)
|
||||
XCTAssertEqual(diagnostic.capabilities.total, 3)
|
||||
XCTAssertEqual(diagnostic.capabilities.supported, 3)
|
||||
XCTAssertEqual(diagnostic.connection?.total, 1)
|
||||
XCTAssertEqual(diagnostic.connection?.availabilityPercent, 100)
|
||||
}
|
||||
|
||||
func testRecordsUnauthorizedDeliveryLogsWithoutLosingConnectionHistory() async throws {
|
||||
let store = MemoryLogStore()
|
||||
let service = LogCenterService(
|
||||
client: LogCenterTestClient(
|
||||
failingRoutes: [.notificationLogs(limit: 50, offset: 0)]
|
||||
),
|
||||
sessions: InstanceSessionStore(),
|
||||
store: store
|
||||
)
|
||||
let instance = Self.instance()
|
||||
|
||||
let snapshot = try await service.refresh([instance])
|
||||
|
||||
XCTAssertEqual(snapshot.runtime.count, 1)
|
||||
XCTAssertEqual(snapshot.runtime.first?.level, .warning)
|
||||
XCTAssertEqual(snapshot.runtime.first?.message, "通知日志未同步:需要登录")
|
||||
let diagnostic = try XCTUnwrap(snapshot.diagnostics.first)
|
||||
XCTAssertEqual(diagnostic.state, .online)
|
||||
XCTAssertEqual(diagnostic.capabilities.authRequired, 1)
|
||||
XCTAssertEqual(diagnostic.capabilities.supported, 2)
|
||||
XCTAssertEqual(store.runtimeStorage.count, 1)
|
||||
XCTAssertEqual(store.connectionStorage.count, 1)
|
||||
}
|
||||
|
||||
func testOfflineProbeBecomesFailedConnectionAndErrorLog() async throws {
|
||||
let store = MemoryLogStore()
|
||||
let service = LogCenterService(
|
||||
client: LogCenterTestClient(connectionUnavailable: true),
|
||||
sessions: InstanceSessionStore(),
|
||||
store: store
|
||||
)
|
||||
|
||||
let snapshot = try await service.refresh([Self.instance()])
|
||||
|
||||
XCTAssertEqual(snapshot.connections.first?.outcome, .failed)
|
||||
XCTAssertEqual(snapshot.connections.first?.errorCode, "TIMEOUT")
|
||||
XCTAssertTrue(snapshot.runtime.contains { $0.level == .error && $0.code == "TIMEOUT" })
|
||||
let diagnostic = try XCTUnwrap(snapshot.diagnostics.first)
|
||||
XCTAssertEqual(diagnostic.state, .offline)
|
||||
XCTAssertEqual(diagnostic.healthStatus, "超时")
|
||||
XCTAssertEqual(diagnostic.capabilities.total, 3)
|
||||
XCTAssertEqual(diagnostic.capabilities.unsupported, 3)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func testViewModelFiltersRuntimeLogsAndPrunesHistory() async throws {
|
||||
let repository = MemoryInstanceRepository()
|
||||
let logStore = MemoryLogStore()
|
||||
let service = LogCenterService(
|
||||
client: LogCenterTestClient(),
|
||||
sessions: InstanceSessionStore(),
|
||||
store: logStore
|
||||
)
|
||||
let model = LogsViewModel(
|
||||
repository: repository,
|
||||
service: service,
|
||||
store: logStore
|
||||
)
|
||||
let instance = try await repository.create(InstanceInput(
|
||||
name: "Log Node",
|
||||
origin: URL(string: "http://192.168.1.20:3000")!,
|
||||
password: nil
|
||||
))
|
||||
|
||||
await model.refresh()
|
||||
|
||||
XCTAssertNotNil(model.snapshot)
|
||||
XCTAssertEqual(model.filteredRuntime.count, 2)
|
||||
XCTAssertEqual(model.filteredDiagnostics.count, 1)
|
||||
XCTAssertEqual(model.offlineCount, 0)
|
||||
|
||||
model.selectedInstanceID = instance.id
|
||||
model.searchText = "offline"
|
||||
model.selectedLevel = .error
|
||||
|
||||
XCTAssertEqual(model.filteredRuntime.count, 1)
|
||||
XCTAssertEqual(model.filteredRuntime.first?.message, "实例进入离线状态")
|
||||
|
||||
let removed = await model.pruneConnections(olderThanDays: 7)
|
||||
XCTAssertEqual(removed, 0)
|
||||
}
|
||||
|
||||
private static func instance() -> Instance {
|
||||
Instance(
|
||||
id: UUID(),
|
||||
name: "Log Node",
|
||||
origin: URL(string: "http://192.168.1.20:3000")!,
|
||||
configRevision: 1,
|
||||
createdAt: Date(),
|
||||
updatedAt: Date()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private final class MemoryLogStore: LogStore, @unchecked Sendable {
|
||||
private let lock = NSLock()
|
||||
private var runtimeItems: [RuntimeLogEntry] = []
|
||||
private var connectionItems: [ConnectionLogEntry] = []
|
||||
|
||||
var runtimeStorage: [RuntimeLogEntry] {
|
||||
lock.withLock { runtimeItems }
|
||||
}
|
||||
|
||||
var connectionStorage: [ConnectionLogEntry] {
|
||||
lock.withLock { connectionItems }
|
||||
}
|
||||
|
||||
func runtimeLogs() async throws -> [RuntimeLogEntry] {
|
||||
runtimeStorage
|
||||
}
|
||||
|
||||
func connectionLogs() async throws -> [ConnectionLogEntry] {
|
||||
connectionStorage
|
||||
}
|
||||
|
||||
func append(
|
||||
runtime: [RuntimeLogEntry],
|
||||
connections: [ConnectionLogEntry]
|
||||
) async throws {
|
||||
lock.withLock {
|
||||
var mergedRuntime = runtime
|
||||
for existing in runtimeItems where !runtime.contains(where: { $0.id == existing.id }) {
|
||||
mergedRuntime.append(existing)
|
||||
}
|
||||
runtimeItems = mergedRuntime.sorted { $0.occurredAt > $1.occurredAt }
|
||||
|
||||
var mergedConnections = connections
|
||||
for existing in connectionItems
|
||||
where !connections.contains(where: { $0.id == existing.id }) {
|
||||
mergedConnections.append(existing)
|
||||
}
|
||||
connectionItems = mergedConnections.sorted { $0.observedAt > $1.observedAt }
|
||||
}
|
||||
}
|
||||
|
||||
func pruneConnections(before _: Date) async throws -> Int {
|
||||
0
|
||||
}
|
||||
}
|
||||
|
||||
private struct LogCenterTestClient: SimAdminClientProtocol {
|
||||
var failingRoutes: Set<UpstreamRoute> = []
|
||||
var connectionUnavailable = false
|
||||
|
||||
func send(_ route: UpstreamRoute, baseURL: URL, cookie: String?) async throws -> UpstreamResponse {
|
||||
if connectionUnavailable {
|
||||
throw UpstreamError.timeout
|
||||
}
|
||||
if failingRoutes.contains(route) {
|
||||
throw UpstreamError.unauthorized
|
||||
}
|
||||
|
||||
switch route {
|
||||
case .health:
|
||||
return Self.response(#"{"status":"UP","version":"1.2.3","platform":"linux"}"#)
|
||||
case .device:
|
||||
return Self.response(
|
||||
#"{"status":"ok","data":{"model":"UFI-003","battery":82,"temperature":42}}"#
|
||||
)
|
||||
case .connectivity:
|
||||
return Self.response(#"{"status":"ok","data":{"connected":true}}"#)
|
||||
case .notificationLogs:
|
||||
return Self.response(
|
||||
"""
|
||||
{"status":"ok","data":{"total":2,"logs":[
|
||||
{"id":"log-1","event_type":"device_offline","status":"success",
|
||||
"message":"实例恢复在线","created_at":"2026-09-08T10:01:00Z"},
|
||||
{"id":"log-2","event_type":"device_offline","status":"failed",
|
||||
"message":"实例进入离线状态","created_at":"2026-09-08T10:02:00Z"}
|
||||
]}}
|
||||
"""
|
||||
)
|
||||
default:
|
||||
return UpstreamResponse(status: 404, data: Data("{}".utf8))
|
||||
}
|
||||
}
|
||||
|
||||
private static func response(_ json: String) -> UpstreamResponse {
|
||||
UpstreamResponse(status: 200, data: Data(json.utf8))
|
||||
}
|
||||
}
|
||||
@@ -16,7 +16,7 @@ xcodebuild -scheme "$APP_NAME" \
|
||||
|
||||
rm -rf "$BUILD_ROOT"
|
||||
mkdir -p "$BUILD_ROOT/Payload/$APP_NAME.app"
|
||||
cp ".build/arm64-apple-ios/release/$APP_NAME" \
|
||||
cp "$DERIVED_ROOT/Build/Products/Release-iphoneos/$APP_NAME" \
|
||||
"$BUILD_ROOT/Payload/$APP_NAME.app/$APP_NAME"
|
||||
cp "Sources/$APP_NAME/Resources/Info.plist" \
|
||||
"$BUILD_ROOT/Payload/$APP_NAME.app/Info.plist"
|
||||
|
||||
Reference in New Issue
Block a user