import Foundation import SwiftUI enum FleetGroupMode: String, CaseIterable, Identifiable { case none case status case groups var id: String { rawValue } var title: String { switch self { case .none: return "平铺" case .status: return "状态" case .groups: return "分组" } } } struct FleetGroup: Identifiable { let id: String let title: String let rows: [FleetViewModel.Row] } struct FleetGroupHealth: Equatable { var total = 0 var online = 0 var abnormal = 0 var testing = 0 var signalValues: [Int] = [] var latencyValues: [Int] = [] var batteryValues: [Double] = [] var storageValues: [Double] = [] var trafficValues: [Double] = [] var onlineValues: [Bool] = [] var abnormalValues: [Bool] = [] private var signalAverage: Double? { average(signalValues.map(Double.init)) } private var latencyAverage: Double? { average(latencyValues.map(Double.init)) } private var batteryAverage: Double? { average(batteryValues) } private var storageAverage: Double? { average(storageValues) } private var trafficTotal: Double { trafficValues.reduce(0, +) } var detail: String { var parts = ["\(total) 台"] if let signal = signalAverage { parts.append("信号 \(Int(signal.rounded()))%") } if let latency = latencyAverage { parts.append("延迟 \(Int(latency.rounded())) ms") } return parts.joined(separator: " · ") } var signalTint: Color? { signalAverage.map { $0 >= 60 ? AITheme.success : ($0 >= 30 ? AITheme.warning : AITheme.danger) } } var latencyTint: Color? { latencyAverage.map { $0 < 300 ? AITheme.success : ($0 < 800 ? AITheme.warning : AITheme.danger) } } var batteryTint: Color? { batteryAverage.map { $0 <= 20 ? AITheme.danger : ($0 <= 40 ? AITheme.warning : AITheme.success) } } var storageTint: Color? { storageAverage.map { $0 >= 0.90 ? AITheme.danger : ($0 >= 0.75 ? AITheme.warning : AITheme.success) } } var trafficTint: Color? { trafficValues.isEmpty ? nil : (trafficTotal >= 50_000_000 ? AITheme.warning : AITheme.success) } var onlineAverageText: String { guard onlineValues.isEmpty == false else { return "不可用" } let ratio = Double(onlineValues.filter { $0 }.count) / Double(onlineValues.count) return "\(Int((ratio * 100).rounded()))%" } var onlineTint: Color? { onlineValues.isEmpty ? nil : (online > 0 ? AITheme.success : AITheme.muted) } var abnormalAverageText: String { guard abnormalValues.isEmpty == false else { return "不可用" } let ratio = Double(abnormalValues.filter { $0 }.count) / Double(abnormalValues.count) return "\(Int((ratio * 100).rounded()))%" } var abnormalTint: Color? { abnormalValues.isEmpty ? nil : (abnormal > 0 ? AITheme.danger : AITheme.success) } private func average(_ values: [Double]) -> Double? { guard values.isEmpty == false else { return nil } return values.reduce(0, +) / Double(values.count) } var signalAverageText: String { signalAverage.map { "\(Int($0.rounded()))%" } ?? "不可用" } var latencyAverageText: String { latencyAverage.map { "\(Int($0.rounded())) ms" } ?? "不可用" } var storageAverageText: String { storageAverage.map { "\(Int(($0 * 100).rounded()))% 已用" } ?? "不可用" } var trafficTotalText: String { guard trafficValues.isEmpty == false else { return "不可用" } return ByteCountFormatter.string(fromByteCount: Int64(trafficTotal), countStyle: .decimal) + "/s" } } @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 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 groupMode: FleetGroupMode = .groups { didSet { preferences.set(groupMode.rawValue, forKey: "fleet.groupMode") } } @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 showingGroupManager = false @Published private(set) var groups: [InstanceGroup] = [] private let repository: any InstanceRepository private let probe: ConnectionProbe private let credentials: any CredentialStore private let deviceService: (any DeviceSummaryService)? private let groupStore: (any InstanceGroupStore)? private let preferences: UserDefaults init( repository: any InstanceRepository, probe: ConnectionProbe, credentials: any CredentialStore, deviceService: (any DeviceSummaryService)? = nil, groupStore: (any InstanceGroupStore)? = nil, preferences: UserDefaults = .standard ) { self.repository = repository self.probe = probe self.credentials = credentials self.deviceService = deviceService self.groupStore = groupStore self.preferences = preferences // 旧版本的「按标签分组」已废弃,标签现在只作为筛选条件存在。 let storedMode = preferences.string(forKey: "fleet.groupMode") groupMode = FleetGroupMode(rawValue: storedMode ?? "") ?? .groups 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 await loadGroups() 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, origin: input.origin, tags: input.tags, groupId: input.groupId, 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)) } @discardableResult func createGroup(named name: String) async throws -> InstanceGroup { guard let groupStore else { throw RepositoryError.notFound } let group = try await groupStore.create(InstanceGroupInput(name: name)) await loadGroups() return group } func updateGroup(_ id: UUID, name: String) async throws { guard let groupStore else { return } _ = try await groupStore.update(id, name) await loadGroups() } func deleteGroup(_ id: UUID) async throws { guard let groupStore else { return } try await groupStore.delete(id) for row in rows where row.instance.groupId == id { _ = try await repository.update( row.instance.id, expectedRevision: row.instance.configRevision, InstancePatch(groupId: nil) ) } await loadGroups() await refresh() } func assign(groupID: UUID?, to ids: [UUID]) async throws { var failures = 0 for id in ids { guard let row = rows.first(where: { $0.id == id }) else { continue } do { _ = try await repository.update( id, expectedRevision: row.instance.configRevision, InstancePatch(groupId: groupID) ) } catch { failures += 1 errorMessage = "更新 \(row.instance.name) 分组失败" } } if failures > 0 { throw RepositoryError.databaseFailed } await refresh() } func loadGroups() async { guard let groupStore else { return } groups = (try? await groupStore.list()) ?? [] } func groupName(for id: UUID?) -> String? { guard let id else { return nil } return groups.first { $0.id == id }?.name } 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 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.origin.absoluteString, row.instance.tags.joined(separator: " "), row.operatorName ?? "", row.phoneNumber ?? "", row.networkTechnology ?? "", row.deviceName ?? "" ] let strings: [String] = haystacks return strings.contains { $0.localizedCaseInsensitiveContains(searchText) } } } 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 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() } var groupedRows: [FleetGroup] { let rows = visibleRows.sorted { $0.instance.name.localizedCompare($1.instance.name) == .orderedAscending } switch groupMode { case .none: return [FleetGroup(id: "", title: "", rows: rows)] case .status: let order: [ConnectionStatus] = [.online, .degraded, .authRequired, .offline, .unknown] return order.compactMap { status in let matched = rows.filter { $0.status == status } guard !matched.isEmpty else { return nil } return FleetGroup(id: status.rawValue, title: status.label, rows: matched) } case .groups: var groupedRows: [UUID: [Row]] = [:] var ungroupedRows: [Row] = [] for row in rows { if let id = row.instance.groupId, groupName(for: id) != nil { groupedRows[id, default: []].append(row) } else { ungroupedRows.append(row) } } let namedGroups = groupedRows.map { key, values in FleetGroup( id: key.uuidString, title: groupName(for: key) ?? "未命名分组", rows: values ) } .sorted { $0.title.localizedCompare($1.title) == .orderedAscending } return namedGroups + (ungroupedRows.isEmpty ? [] : [FleetGroup(id: "__ungrouped__", title: "未分组", rows: ungroupedRows)]) } } func health(for group: FleetGroup) -> FleetGroupHealth { FleetGroupHealth( total: group.rows.count, online: group.rows.filter { $0.status == .online }.count, abnormal: group.rows.filter { $0.status != .online && $0.status != .unknown }.count, testing: group.rows.filter(\.testing).count, signalValues: group.rows.compactMap(\.signalPercent), latencyValues: group.rows.compactMap(\.latencyMilliseconds), batteryValues: group.rows.compactMap(\.batteryPercent), storageValues: group.rows.compactMap(\.storageUsageRatio), trafficValues: group.rows.compactMap(\.downloadRateBytesPerSecond) + group.rows.compactMap(\.uploadRateBytesPerSecond) , onlineValues: group.rows.map { $0.status == .online }, abnormalValues: group.rows.map { $0.status != .online && $0.status != .unknown } ) } } protocol DeviceSummaryService { func summary(_ instance: Instance) async throws -> DeviceSummary } extension DeviceService: DeviceSummaryService {}