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 let autoRefreshOnAppear: Bool init( repository: any InstanceRepository, service: LogCenterService, store: any LogStore, autoRefreshOnAppear: Bool = true ) { self.repository = repository self.service = service self.store = store self.autoRefreshOnAppear = autoRefreshOnAppear } 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 } 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 } }