import Foundation import SwiftUI @MainActor final class FleetViewModel: ObservableObject { struct Row: Equatable, Identifiable { let id: UUID var instance: Instance var status: ConnectionStatus var deviceName: String? var phoneNumber: String? var operatorName: String? var networkTechnology: String? var signalPercent: Int? var cpuPercent: Double? var memoryPercent: Double? var cpuCount: Int? var temperatureCelsius: Double? var batteryPercent: Double? var ipAddress: String? var latencyMilliseconds: Int? var connectivity: String? var firmwareVersion: String? var totalStorageBytes: Int64? var availableStorageBytes: Int64? var downloadRateBytesPerSecond: Double? var uploadRateBytesPerSecond: Double? var messagesToday: Int? var uptimeSeconds: Double? var chainValues: DeviceLinkState? var testing: Bool /// Why the last probe ended where it did, for the detail text. var probeError: UpstreamError? /// When the last probe finished. The list shows how old a reading is, which in relay mode is /// the difference between "online" meaning now and meaning forty seconds ago. var checkedAt: Date? /// Which path this node is reached by. The badge exists because without it "this one is /// slow" has no answer that a user can act on. var channel: InstanceChannel { instance.endpoint.channel } var freshness: SnapshotFreshness { testing ? .unknown : SnapshotFreshness.classify(age: checkedAt.map { Date().timeIntervalSince($0) }) } var storageUsageRatio: Double? { guard let total = totalStorageBytes, let available = availableStorageBytes, total > 0 else { return nil } return Double(max(0, total - available)) / Double(total) } init( id: UUID, instance: Instance, status: ConnectionStatus = .unknown, testing: Bool = true ) { self.id = id self.instance = instance self.status = status self.testing = testing } } @Published var rows: [Row] = [] @Published var searchText = "" @Published var statusFilter: ConnectionStatus? @Published var selectedTagFilters: Set = [] { didSet { let values = Array(selectedTagFilters).sorted() preferences.set(values, forKey: "fleet.selectedTagFilters") } } @Published var selectedIDs: Set = [] @Published var selectionMode = false @Published var isLoading = false @Published private(set) var lastRefresh: Date? @Published var errorMessage: String? @Published var showingForm = false @Published var showingTagBatch = false private let repository: any InstanceRepository private let probe: ConnectionProbe private let credentials: any CredentialStore private let deviceService: (any DeviceSummaryService)? private let preferences: UserDefaults init( repository: any InstanceRepository, probe: ConnectionProbe, credentials: any CredentialStore, deviceService: (any DeviceSummaryService)? = nil, preferences: UserDefaults = .standard ) { self.repository = repository self.probe = probe self.credentials = credentials self.deviceService = deviceService self.preferences = preferences selectedTagFilters = Set(preferences.stringArray(forKey: "fleet.selectedTagFilters") ?? []) } /// 节点列表变化后,丢弃已经不存在任何实例的标签筛选,避免列表被隐藏条件清空。 func pruneTagFilters() { guard selectedTagFilters.isEmpty == false else { return } let live = selectedTagFilters.filter { countForTagFilter($0) > 0 } if live != selectedTagFilters { selectedTagFilters = live } } func refresh() async { isLoading = true errorMessage = nil do { let query = InstanceQuery(search: searchText.isEmpty ? nil : searchText) let instances = try await repository.list(query) rows = instances.map { Row(id: $0.id, instance: $0, status: .unknown, testing: true) } await withTaskGroup(of: Void.self) { group in for instance in instances { group.addTask { [probe, deviceService] in let result = try? await probe.test(instance) let summary = try? await deviceService?.summary(instance) await self.applyProbe(instance.id, result, summary) } } } } catch { errorMessage = "无法刷新节点列表" } pruneTagFilters() lastRefresh = Date() isLoading = false } @discardableResult func add(_ input: InstanceInput) async throws -> Instance { let created = try await repository.create(input) if let password = input.password, !password.isEmpty { try credentials.set(instanceID: created.id, password: password) } showingForm = false await refresh() return created } func update(_ id: UUID, _ input: InstanceInput) async throws { guard let original = rows.first(where: { $0.id == id }) else { throw RepositoryError.notFound } _ = try await repository.update( id, expectedRevision: original.instance.configRevision, InstancePatch( name: input.name, endpoint: input.endpoint, tags: input.tags, password: input.password ) ) if let password = input.password, !password.isEmpty { try credentials.set(instanceID: id, password: password) } showingForm = false await refresh() } func delete(_ ids: [UUID]) async { for id in ids { if let row = rows.first(where: { $0.id == id }) { do { try await repository.delete(id, expectedRevision: row.instance.configRevision) try credentials.remove(instanceID: id) } catch { errorMessage = "删除 \(row.instance.name) 失败" } } } selectedIDs.removeAll() selectionMode = false await refresh() } func deleteSelected() async { await delete(Array(selectedIDs)) } /// 批量给选中的节点增删标签:标签是唯一的归类手段,所以这里必须能成批操作。 func applyTag(_ tag: String, to ids: [UUID], removing: Bool = false) async { let normalized = Self.normalizedTag(tag) guard normalized.isEmpty == false else { return } var failures = 0 for id in ids { guard let row = rows.first(where: { $0.id == id }) else { continue } var updated = row.instance.tags.filter { Self.normalizedTag($0) != normalized } if removing == false { updated.append(tag.trimmingCharacters(in: .whitespacesAndNewlines)) } do { _ = try await repository.update( id, expectedRevision: row.instance.configRevision, InstancePatch(tags: updated) ) } catch { failures += 1 errorMessage = "更新 \(row.instance.name) 标签失败" } } if failures > 0, errorMessage == nil { errorMessage = "部分节点标签更新失败" } await refresh() } func tagNames(in ids: [UUID]) -> [String] { let set = Set(rows.filter { ids.contains($0.id) }.flatMap { $0.instance.tags.map(Self.normalizedTag) }) return set.sorted { $0.localizedCompare($1) == .orderedAscending } } private func applyProbe( _ id: UUID, _ result: ConnectionResult?, _ summary: DeviceSummary? ) { guard let index = rows.firstIndex(where: { $0.id == id }) else { return } rows[index].status = result?.status ?? .unknown rows[index].probeError = result?.error rows[index].checkedAt = result?.checkedAt if let summary { rows[index].deviceName = summary.deviceName rows[index].phoneNumber = summary.phoneNumber rows[index].operatorName = summary.operatorName rows[index].networkTechnology = summary.networkTechnology rows[index].signalPercent = summary.signalPercent rows[index].cpuPercent = summary.cpuPercent rows[index].memoryPercent = summary.memoryPercent rows[index].cpuCount = summary.cpuCount rows[index].temperatureCelsius = summary.temperatureCelsius rows[index].batteryPercent = summary.batteryPercent rows[index].ipAddress = summary.ipAddress rows[index].latencyMilliseconds = summary.latencyMilliseconds rows[index].connectivity = summary.connectivity rows[index].firmwareVersion = summary.firmwareVersion rows[index].totalStorageBytes = summary.totalStorageBytes rows[index].availableStorageBytes = summary.availableStorageBytes rows[index].downloadRateBytesPerSecond = summary.downloadRateBytesPerSecond rows[index].uploadRateBytesPerSecond = summary.uploadRateBytesPerSecond rows[index].messagesToday = summary.messagesToday rows[index].uptimeSeconds = summary.uptimeSeconds rows[index].chainValues = summary.linkState } if rows[index].latencyMilliseconds == nil { rows[index].latencyMilliseconds = result?.requestLatencyMilliseconds } rows[index].testing = false } /// 列表顺序:状态差的排前面,同组内按名称,扫读时问题节点一定在第一屏。 var visibleRows: [Row] { rows.filter { row in if let statusFilter, row.status != statusFilter { return false } if selectedTagFilters.isEmpty == false { let tags = Set(row.instance.tags.map { Self.normalizedTag($0) }) let needles = Set(selectedTagFilters.map { Self.normalizedTag($0) }) guard needles.isSubset(of: tags) else { return false } } guard !searchText.isEmpty else { return true } let haystacks: [String] = [ row.instance.name, row.instance.endpoint.displayAddress, row.instance.tags.joined(separator: " "), row.operatorName ?? "", row.phoneNumber ?? "", row.networkTechnology ?? "", row.deviceName ?? "" ] let strings: [String] = haystacks return strings.contains { $0.localizedCaseInsensitiveContains(searchText) } } .sorted { lhs, rhs in let left = severity(lhs.status) let right = severity(rhs.status) if left != right { return left < right } return lhs.instance.name.localizedCompare(rhs.instance.name) == .orderedAscending } } private func severity(_ status: ConnectionStatus) -> Int { switch status { case .offline: return 0 case .authRequired, .degraded: return 1 case .unknown: return 2 case .online: return 3 } } var allTags: [String] { Array(tagCounts.keys).sorted { $0.localizedCompare($1) == .orderedAscending } } var tagCounts: [String: Int] { var counts: [String: Int] = [:] for row in rows { for tag in Set(row.instance.tags.map(Self.normalizedTag)) where tag.isEmpty == false { counts[tag, default: 0] += 1 } } return counts } func countForTagFilter(_ tag: String) -> Int { tagCounts[Self.normalizedTag(tag)] ?? 0 } func isTagFilterActive(_ tag: String) -> Bool { selectedTagFilters.contains(Self.normalizedTag(tag)) } /// 选中的这批节点里,有多少台已经带上这个标签:决定胶囊显示"全部/部分/未标记"。 func tagCoverage(_ tag: String, in ids: [UUID]) -> Int { let needle = Self.normalizedTag(tag) guard needle.isEmpty == false else { return 0 } let targets = Set(ids) return rows.filter { row in targets.contains(row.id) && row.instance.tags.map(Self.normalizedTag).contains(needle) }.count } func toggleTagFilter(_ tag: String) { let normalized = Self.normalizedTag(tag) guard normalized.isEmpty == false else { return } if selectedTagFilters.contains(normalized) { selectedTagFilters.remove(normalized) } else { selectedTagFilters.insert(normalized) } } func clearTagFilters() { guard selectedTagFilters.isEmpty == false else { return } selectedTagFilters.removeAll() } private static func normalizedTag(_ tag: String) -> String { tag.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() } } protocol DeviceSummaryService { func summary(_ instance: Instance) async throws -> DeviceSummary } extension DeviceService: DeviceSummaryService {}