From 9c3186252b0f031bbd2bfe938712fcd9f42dad33 Mon Sep 17 00:00:00 2001 From: chick Date: Wed, 9 Sep 2026 01:53:40 +0800 Subject: [PATCH] feat(ios): add cross-instance log center --- .../Features/Logs/LogCenterModels.swift | 200 ++++++++++ .../Features/Logs/LogCenterService.swift | 372 ++++++++++++++++++ .../Features/Logs/LogsView.swift | 301 ++++++++++++++ .../Features/Logs/LogsViewModel.swift | 132 +++++++ .../MultiSimAdmin/MultiSimAdminApp.swift | 2 +- .../Persistence/FileLogStore.swift | 104 +++++ .../Services/DefaultContainer.swift | 15 + .../MultiSimAdminTests/LogCenterTests.swift | 219 +++++++++++ scripts/build-ios-ipa.sh | 2 +- 9 files changed, 1345 insertions(+), 2 deletions(-) create mode 100644 apps/ios/Sources/MultiSimAdmin/Features/Logs/LogCenterModels.swift create mode 100644 apps/ios/Sources/MultiSimAdmin/Features/Logs/LogCenterService.swift create mode 100644 apps/ios/Sources/MultiSimAdmin/Features/Logs/LogsView.swift create mode 100644 apps/ios/Sources/MultiSimAdmin/Features/Logs/LogsViewModel.swift create mode 100644 apps/ios/Sources/MultiSimAdmin/Persistence/FileLogStore.swift create mode 100644 apps/ios/Tests/MultiSimAdminTests/LogCenterTests.swift diff --git a/apps/ios/Sources/MultiSimAdmin/Features/Logs/LogCenterModels.swift b/apps/ios/Sources/MultiSimAdmin/Features/Logs/LogCenterModels.swift new file mode 100644 index 0000000..37f69fc --- /dev/null +++ b/apps/ios/Sources/MultiSimAdmin/Features/Logs/LogCenterModels.swift @@ -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 +} diff --git a/apps/ios/Sources/MultiSimAdmin/Features/Logs/LogCenterService.swift b/apps/ios/Sources/MultiSimAdmin/Features/Logs/LogCenterService.swift new file mode 100644 index 0000000..39c7a19 --- /dev/null +++ b/apps/ios/Sources/MultiSimAdmin/Features/Logs/LogCenterService.swift @@ -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] = [ + "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 { + 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], + 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() + } +} diff --git a/apps/ios/Sources/MultiSimAdmin/Features/Logs/LogsView.swift b/apps/ios/Sources/MultiSimAdmin/Features/Logs/LogsView.swift new file mode 100644 index 0000000..0486cb1 --- /dev/null +++ b/apps/ios/Sources/MultiSimAdmin/Features/Logs/LogsView.swift @@ -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.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.none) + ForEach(RuntimeLogSource.allCases, id: \.self) { source in + Text(source.label).tag(Optional(source)) + } + } + .pickerStyle(.menu) + + Picker("级别", selection: $model.selectedLevel) { + Text("全部级别").tag(Optional.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.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 + } + } +} diff --git a/apps/ios/Sources/MultiSimAdmin/Features/Logs/LogsViewModel.swift b/apps/ios/Sources/MultiSimAdmin/Features/Logs/LogsViewModel.swift new file mode 100644 index 0000000..4eae1a9 --- /dev/null +++ b/apps/ios/Sources/MultiSimAdmin/Features/Logs/LogsViewModel.swift @@ -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 + } +} diff --git a/apps/ios/Sources/MultiSimAdmin/MultiSimAdminApp.swift b/apps/ios/Sources/MultiSimAdmin/MultiSimAdminApp.swift index d8e0834..57d23e6 100644 --- a/apps/ios/Sources/MultiSimAdmin/MultiSimAdminApp.swift +++ b/apps/ios/Sources/MultiSimAdmin/MultiSimAdminApp.swift @@ -17,7 +17,7 @@ struct MultiSimAdminApp: App { .tabItem { Label("通知", systemImage: "bell") } - Text("日志") + LogsView() .tabItem { Label("日志", systemImage: "doc.text") } diff --git a/apps/ios/Sources/MultiSimAdmin/Persistence/FileLogStore.swift b/apps/ios/Sources/MultiSimAdmin/Persistence/FileLogStore.swift new file mode 100644 index 0000000..635d4f1 --- /dev/null +++ b/apps/ios/Sources/MultiSimAdmin/Persistence/FileLogStore.swift @@ -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( + _ 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 + } + } +} diff --git a/apps/ios/Sources/MultiSimAdmin/Services/DefaultContainer.swift b/apps/ios/Sources/MultiSimAdmin/Services/DefaultContainer.swift index ec4a907..4b1e659 100644 --- a/apps/ios/Sources/MultiSimAdmin/Services/DefaultContainer.swift +++ b/apps/ios/Sources/MultiSimAdmin/Services/DefaultContainer.swift @@ -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( diff --git a/apps/ios/Tests/MultiSimAdminTests/LogCenterTests.swift b/apps/ios/Tests/MultiSimAdminTests/LogCenterTests.swift new file mode 100644 index 0000000..50f64c4 --- /dev/null +++ b/apps/ios/Tests/MultiSimAdminTests/LogCenterTests.swift @@ -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 = [] + 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)) + } +} diff --git a/scripts/build-ios-ipa.sh b/scripts/build-ios-ipa.sh index bccdaa5..244dbde 100755 --- a/scripts/build-ios-ipa.sh +++ b/scripts/build-ios-ipa.sh @@ -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"