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.
132 lines
5.0 KiB
Swift
132 lines
5.0 KiB
Swift
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
|
|
}
|
|
}
|